From bc9135e7cd3dfac380adaa35864bc18da08d1646 Mon Sep 17 00:00:00 2001 From: Owoh Chidubem Alexander Date: Tue, 23 Jun 2026 17:14:15 +0100 Subject: [PATCH] test: expand and consolidate playwright e2e suite with cross-browser matrix --- .github/workflows/ci.yml | 14 + .gitignore | 6 + e2e/README.md | 125 +++++++++ e2e/fixtures.ts | 290 +++++++++++++++++++++ e2e/stellar.spec.ts | 375 +++++++++++++++++++++++++++ package.json | 6 +- playwright.config.ts | 53 ++++ pnpm-lock.yaml | 87 +++++-- src/components/StellarReceive.tsx | 19 +- src/components/StellarSend.tsx | 15 +- src/components/WalletConnect.tsx | 20 +- src/context/StellarWalletContext.tsx | 15 +- 12 files changed, 991 insertions(+), 34 deletions(-) create mode 100644 e2e/README.md create mode 100644 e2e/fixtures.ts create mode 100644 e2e/stellar.spec.ts create mode 100644 playwright.config.ts diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index ab26f74..34b0be8 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -25,3 +25,17 @@ jobs: - run: pnpm install --frozen-lockfile - run: pnpm format:check - run: pnpm build + + - name: Install Playwright Browsers + run: pnpm exec playwright install --with-deps + + - name: Run Playwright E2E tests + run: pnpm test:e2e + + - name: Upload Playwright Report + uses: actions/upload-artifact@v4 + if: always() + with: + name: playwright-report + path: playwright-report/ + retention-days: 30 diff --git a/.gitignore b/.gitignore index 63f68a4..04ccd83 100644 --- a/.gitignore +++ b/.gitignore @@ -4,3 +4,9 @@ dist/ .env.* reference/ *.local + +# Playwright +test-results/ +playwright-report/ +blob-report/ +playwright/.cache/ diff --git a/e2e/README.md b/e2e/README.md new file mode 100644 index 0000000..41e06a7 --- /dev/null +++ b/e2e/README.md @@ -0,0 +1,125 @@ +# Playwright E2E Test Suite + +This directory contains the end-to-end (E2E) integration test suite for the Wraith Protocol Demo app, specifically focusing on Stellar stealth payments. + +## Directory Structure + +``` +e2e/ +├── fixtures.ts # Shared test fixtures (mocked Freighter, mocked Horizon, mocked Soroban RPC) +├── stellar.spec.ts # Stellar E2E scenarios +└── README.md # This documentation +``` + +## Running Tests + +First, install the Playwright browser binaries: + +```bash +pnpm playwright install +``` + +### Run all tests (headless, all browsers) + +```bash +pnpm test:e2e +``` + +### Run tests in interactive UI mode + +This starts the Playwright test runner UI, enabling step-by-step visual debugging: + +```bash +pnpm test:e2e:ui +``` + +### Debug a specific test + +```bash +pnpm playwright test e2e/stellar.spec.ts --project=chromium --debug +``` + +--- + +## How It Works (Mocking Architecture) + +Since interacting with real wallet extensions (like Freighter) and testnet contracts can be slow and unpredictable, we use a completely local, deterministic mocking architecture defined in `e2e/fixtures.ts`. + +### 1. Mocking Freighter Wallet (`freighter` fixture) + +The E2E suite updates `src/context/StellarWalletContext.tsx` to check for `window.freighterMock`. If present, it uses this mock instead of dynamically importing `@stellar/freighter-api`. + +In your spec, configure the mock wallet: + +```typescript +await freighter.mock({ + isConnected: true, + address: 'GDTESTING...', + signedMessage: '...', // Mock signature payload (base64) +}); +``` + +### 2. Mocking Network Calls (`horizon` fixture) + +The `horizon` fixture intercepts HTTP requests made by the page: + +- `GET https://horizon-testnet.stellar.org/accounts/*` maps to your configured balance/sequence. +- `POST https://horizon-testnet.stellar.org/transactions` returns successful or failed transaction responses. +- `POST https://soroban-testnet.stellar.org` intercepts Soroban JSON-RPC calls, resolving contract states and simulate/send/get transactions. + +In your spec: + +```typescript +await horizon.mock({ + accountExists: true, + accountBalance: '500', + txSuccess: true, + txHash: 'tx_hash_123', +}); +``` + +To mock custom contract announcements (e.g. stealth address events), pass them under `sorobanEvents`: + +```typescript +await horizon.mock({ + sorobanEvents: [ + { + schemeId: 1, + stealthAddress: 'GDQ...', + caller: 'GDT...', + ephemeralPubKey: new Uint8Array(32), + viewTag: 123, + }, + ], +}); +``` + +--- + +## How to Add a New Spec + +1. **Create a spec file**: Add a new file `e2e/your-feature.spec.ts`. +2. **Import test and expect**: Use the custom fixtures: + ```typescript + import { test, expect } from './fixtures'; + ``` +3. **Write the test structure**: + + ```typescript + test.describe('My Feature E2E Suite', () => { + test('Scenario title', async ({ page, freighter, horizon }) => { + // 1. Mock wallet and network state + await freighter.mock({ isConnected: true }); + await horizon.mock({ accountBalance: '100' }); + + // 2. Perform page interactions + await page.goto('/send'); + // ... + + // 3. Assert states + await expect(page.getByText('Success')).toBeVisible(); + }); + }); + ``` + +4. **Register or run**: Playwright automatically finds any files ending in `.spec.ts` in the `e2e/` folder. diff --git a/e2e/fixtures.ts b/e2e/fixtures.ts new file mode 100644 index 0000000..dad9455 --- /dev/null +++ b/e2e/fixtures.ts @@ -0,0 +1,290 @@ +import { test as base } from '@playwright/test'; +import { xdr, Address, nativeToScVal } from '@stellar/stellar-sdk'; + +export interface FreighterMockConfig { + isConnected: boolean; + address: string | null; + signedMessage: string | null; // base64 + signedTxXdr: string | null; + shouldFailConnect?: boolean; + shouldFailSignMessage?: boolean; + shouldFailSignTx?: boolean; + autoConnect?: boolean; +} + +export interface HorizonMockConfig { + accountExists: boolean; + accountBalance: string; + txSuccess: boolean; + txHash?: string; + txErrorCode?: string; + sorobanEvents?: Array<{ + schemeId: number; + stealthAddress: string; + caller: string; + ephemeralPubKey: Uint8Array; + viewTag: number; + }>; + sorobanSimulateSuccess?: boolean; + sorobanSimulateError?: string; + sorobanTxStatus?: string; + address?: string; +} + +const DEFAULT_WALLET_ADDRESS = 'GCDURJMLJBNVUVWXZ7UBXEIAEC4ONEWPWK6KDUUSDTUJJGXCSMBC2XHX'; + +export const test = base.extend<{ + freighter: { + mock: (config: Partial) => Promise; + }; + horizon: { + mock: (config: Partial) => Promise; + }; +}>({ + freighter: async ({ page }, use) => { + const mock = async (config: Partial) => { + await page.addInitScript((cfg) => { + (window as any).freighterMock = { + isConnected: async () => ({ isConnected: cfg.isConnected !== false }), + authorized: false, + getAddress: async function () { + if (cfg.shouldFailConnect) { + return { address: '', error: 'Access denied' }; + } + if (this.authorized || cfg.autoConnect) { + return { + address: cfg.address || 'GCDURJMLJBNVUVWXZ7UBXEIAEC4ONEWPWK6KDUUSDTUJJGXCSMBC2XHX', + }; + } + return { address: '' }; + }, + requestAccess: async function () { + if (cfg.shouldFailConnect) { + throw new Error('User rejected connection'); + } + this.authorized = true; + return { + address: cfg.address || 'GCDURJMLJBNVUVWXZ7UBXEIAEC4ONEWPWK6KDUUSDTUJJGXCSMBC2XHX', + }; + }, + signMessage: async (message: string) => { + if (cfg.shouldFailSignMessage) { + throw new Error('User rejected signature'); + } + const defaultSig = + 'AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA=='; + return { + signedMessage: cfg.signedMessage || defaultSig, + }; + }, + signTransaction: async (xdrString: string) => { + if (cfg.shouldFailSignTx) { + throw new Error('User rejected transaction signing'); + } + return { + signedTxXdr: cfg.signedTxXdr || xdrString, + }; + }, + }; + }, config); + }; + await use({ mock }); + }, + + horizon: async ({ page }, use) => { + const mock = async (config: Partial) => { + const mergedConfig = { + accountExists: true, + accountBalance: '1000', + txSuccess: true, + txHash: 'mocked_tx_hash_1234567890', + txErrorCode: '', + sorobanEvents: [], + sorobanSimulateSuccess: true, + sorobanSimulateError: '', + sorobanTxStatus: 'SUCCESS', + ...config, + }; + + // Set up in-page variables for Soroban Mock Server and Scanning + await page.addInitScript((cfg) => { + (window as any).sorobanServerMock = { + getAccount: async (address: string) => { + if ( + address !== 'GCDURJMLJBNVUVWXZ7UBXEIAEC4ONEWPWK6KDUUSDTUJJGXCSMBC2XHX' && + address !== cfg.address && + !cfg.accountExists + ) { + return Promise.reject({ + code: 404, + message: `Account not found: ${address}`, + }); + } + return { + accountId: () => address, + sequenceNumber: () => '1', + }; + }, + simulateTransaction: async (tx: any) => { + if (!cfg.sorobanSimulateSuccess) { + return { error: cfg.sorobanSimulateError || 'Simulation failed' }; + } + return { + results: [{ auth: [], retval: { type: 'void' } }], + minResourceFee: '100', + transactionData: 'AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA=', + }; + }, + sendTransaction: async (tx: any) => { + return { + status: 'PENDING', + hash: cfg.txHash || 'mocked_soroban_tx_hash', + }; + }, + getTransaction: async (hash: string) => { + return { + status: cfg.sorobanTxStatus || 'SUCCESS', + hash, + }; + }, + }; + + (window as any).scanAnnouncementsMock = ( + announcements: any[], + viewingKey: any, + spendingPubKey: any, + spendingScalar: any, + ) => { + if (cfg.sorobanEvents && cfg.sorobanEvents.length > 0) { + return cfg.sorobanEvents.map((e) => ({ + stealthAddress: e.stealthAddress, + stealthPrivateScalar: 123456789n, + stealthPubKeyBytes: new Uint8Array([ + 23, 255, 173, 128, 104, 220, 13, 233, 147, 93, 54, 99, 111, 58, 209, 181, 222, 109, + 227, 65, 59, 18, 56, 142, 69, 59, 5, 242, 164, 193, 211, 219, + ]), + })); + } + return []; + }; + }, mergedConfig); + + // Route Horizon accounts calls + await page.route('https://horizon-testnet.stellar.org/accounts/*', async (route) => { + const url = route.request().url(); + const address = url.split('/accounts/').pop()?.split('?')[0] || ''; + + const isSender = address === DEFAULT_WALLET_ADDRESS || address === config.address; + + if (isSender || mergedConfig.accountExists) { + await route.fulfill({ + status: 200, + contentType: 'application/json', + body: JSON.stringify({ + id: address, + sequence: '1', + balances: [{ asset_type: 'native', balance: mergedConfig.accountBalance }], + subentry_count: 0, + }), + }); + } else { + await route.fulfill({ + status: 404, + contentType: 'application/json', + body: JSON.stringify({ + title: 'Resource Missing', + status: 404, + }), + }); + } + }); + + // Route Horizon transactions submission + await page.route('https://horizon-testnet.stellar.org/transactions', async (route) => { + if (route.request().method() === 'POST') { + if (mergedConfig.txSuccess) { + await route.fulfill({ + status: 200, + contentType: 'application/json', + body: JSON.stringify({ + hash: mergedConfig.txHash, + ledger: 100, + }), + }); + } else { + await route.fulfill({ + status: 400, + contentType: 'application/json', + body: JSON.stringify({ + title: 'Transaction Failed', + extras: { + result_codes: { + transaction: mergedConfig.txErrorCode || 'tx_failed', + }, + }, + }), + }); + } + } else { + await route.continue(); + } + }); + + // Map mock events from Node config to base64 JSON-RPC structure + const base64Events = (mergedConfig.sorobanEvents || []).map((e) => { + const schemeIdScVal = nativeToScVal(e.schemeId, { type: 'u32' }); + const stealthScVal = new Address(e.stealthAddress).toScVal(); + const valueVec = [ + new Address(e.caller).toScVal(), + xdr.ScVal.scvBytes(Buffer.from(e.ephemeralPubKey)), + xdr.ScVal.scvBytes(Buffer.from([e.viewTag])), + ]; + const valueScVal = xdr.ScVal.scvVec(valueVec); + + return { + topic: [ + xdr.ScVal.scvSymbol('announce').toXDR('base64'), + schemeIdScVal.toXDR('base64'), + stealthScVal.toXDR('base64'), + ], + value: valueScVal.toXDR('base64'), + contractId: 'CCJLJ2QRBJAAKIG6ELNQVXLLWMKKWVN5O2FKWUETHZGMPAD4MHK7WVWL', + }; + }); + + // Route Soroban RPC calls (specifically for getEvents) + await page.route('https://soroban-testnet.stellar.org', async (route) => { + if (route.request().method() === 'POST') { + const body = route.request().postDataJSON(); + const id = body?.id || 1; + + if (body?.method === 'getEvents') { + await route.fulfill({ + status: 200, + contentType: 'application/json', + body: JSON.stringify({ + jsonrpc: '2.0', + id, + result: { + events: base64Events, + }, + }), + }); + } else { + await route.fulfill({ + status: 200, + contentType: 'application/json', + body: JSON.stringify({ jsonrpc: '2.0', id, result: {} }), + }); + } + } else { + await route.continue(); + } + }); + }; + + await use({ mock }); + }, +}); + +export { expect } from '@playwright/test'; diff --git a/e2e/stellar.spec.ts b/e2e/stellar.spec.ts new file mode 100644 index 0000000..4b0064b --- /dev/null +++ b/e2e/stellar.spec.ts @@ -0,0 +1,375 @@ +import { test, expect } from './fixtures'; + +test.describe('Stellar Stealth Payments E2E Suite', () => { + test.beforeEach(async ({ page, context }) => { + page.on('console', (msg) => { + if (msg.type() === 'error') { + console.error('BROWSER ERROR:', msg.text()); + } else { + console.log('BROWSER LOG:', msg.text()); + } + }); + + // Grant clipboard permissions for copy-paste tests if supported (only Chromium supports this) + try { + await context.grantPermissions(['clipboard-read', 'clipboard-write']); + } catch { + // Ignored for non-chromium browsers + } + }); + + test('Wallet - 1. Shows Connect Freighter button by default when Stellar chain is selected', async ({ + page, + freighter, + }) => { + await freighter.mock({ isConnected: false }); + await page.goto('/send'); + await page.selectOption('select', 'stellar'); + + // Verify correct page state when disconnected + await expect(page.locator('h1')).toHaveText('Send'); + await expect( + page.getByText('Connect your Freighter wallet to send stealth payments on Stellar.'), + ).toBeVisible(); + await expect(page.getByRole('button', { name: 'Connect Freighter' })).toBeVisible(); + }); + + test('Wallet - 2. Shows installation error if Freighter wallet is not installed', async ({ + page, + freighter, + }) => { + // Mock wallet NOT installed + await freighter.mock({ isConnected: false }); + await page.goto('/send'); + await page.selectOption('select', 'stellar'); + + await page.getByRole('button', { name: 'Connect Freighter' }).click(); + + // Verify error message is shown + await expect( + page.getByText('Freighter wallet not found. Please install the Freighter browser extension.'), + ).toBeVisible(); + }); + + test('Wallet - 3. Handles user rejecting connection request', async ({ page, freighter }) => { + // Mock installed, but connection will fail + await freighter.mock({ isConnected: true, shouldFailConnect: true }); + await page.goto('/send'); + await page.selectOption('select', 'stellar'); + + await page.getByRole('button', { name: 'Connect Freighter' }).click(); + + // Verify error from wallet + await expect(page.getByText('User rejected connection')).toBeVisible(); + }); + + test('Wallet - 4. Successfully connects and triggers auto-signing key derivation', async ({ + page, + freighter, + }) => { + const mockAddress = 'GCDURJMLJBNVUVWXZ7UBXEIAEC4ONEWPWK6KDUUSDTUJJGXCSMBC2XHX'; + await freighter.mock({ + isConnected: true, + address: mockAddress, + }); + + await page.goto('/send'); + await page.selectOption('select', 'stellar'); + await page.getByRole('button', { name: 'Connect Freighter' }).click(); + + // Header should show connected wallet address (truncated: GCDU...2XHX) + await expect(page.getByRole('button', { name: 'GCDU...2XHX' })).toBeVisible(); + + // Send form should now be visible since wallet is connected + await expect(page.getByLabel('Recipient Meta-Address')).toBeVisible(); + }); + + test('Wallet - 5. Disconnects wallet correctly when address button is clicked', async ({ + page, + freighter, + }) => { + const mockAddress = 'GCDURJMLJBNVUVWXZ7UBXEIAEC4ONEWPWK6KDUUSDTUJJGXCSMBC2XHX'; + await freighter.mock({ isConnected: true, address: mockAddress }); + + await page.goto('/send'); + await page.selectOption('select', 'stellar'); + await page.getByRole('button', { name: 'Connect Freighter' }).click(); + + // Click on the connected button (displays address) to disconnect + await page.getByRole('button', { name: 'GCDU...2XHX' }).click(); + + // Should return to disconnected state + await expect(page.getByRole('button', { name: 'Connect Freighter' })).toBeVisible(); + }); + + test('Send - 6. Validates empty input fields and keeps Send button disabled', async ({ + page, + freighter, + }) => { + const mockAddress = 'GCDURJMLJBNVUVWXZ7UBXEIAEC4ONEWPWK6KDUUSDTUJJGXCSMBC2XHX'; + await freighter.mock({ isConnected: true, address: mockAddress }); + + await page.goto('/send'); + await page.selectOption('select', 'stellar'); + await page.getByRole('button', { name: 'Connect Freighter' }).click(); + + const sendBtn = page.getByRole('button', { name: 'Send Privately' }); + await expect(sendBtn).toBeDisabled(); + + // Fill only recipient + await page.getByPlaceholder('st:xlm:...').fill('st:xlm:someaddress'); + await expect(sendBtn).toBeDisabled(); + + // Clear recipient, fill only amount + await page.getByPlaceholder('st:xlm:...').clear(); + await page.getByPlaceholder('0.0').fill('10.5'); + await expect(sendBtn).toBeDisabled(); + }); + + test('Send - 7. Validates recipient address format prefix', async ({ page, freighter }) => { + const mockAddress = 'GCDURJMLJBNVUVWXZ7UBXEIAEC4ONEWPWK6KDUUSDTUJJGXCSMBC2XHX'; + await freighter.mock({ isConnected: true, address: mockAddress }); + + await page.goto('/send'); + await page.selectOption('select', 'stellar'); + await page.getByRole('button', { name: 'Connect Freighter' }).click(); + + // Enter wrong prefix + await page.getByPlaceholder('st:xlm:...').fill('st:eth:invalidprefixaddress'); + await page.getByPlaceholder('0.0').fill('10'); + await page.getByRole('button', { name: 'Send Privately' }).click(); + + await expect(page.getByText('Enter a valid Stellar meta-address (st:xlm:...)')).toBeVisible(); + }); + + test('Send - 8. Successfully executes payment to a new stealth address (creating the account)', async ({ + page, + freighter, + horizon, + }) => { + const mockAddress = 'GCDURJMLJBNVUVWXZ7UBXEIAEC4ONEWPWK6KDUUSDTUJJGXCSMBC2XHX'; + const recipientMetaAddress = + 'st:xlm:5a1922b5614eed2ef72ebad40abc5d014f7c27b6e1de5dc36976e9eec4cbe29e6b912a495f9f14513d54a00a7887f986d394a30a77239475caf211e8094b6cdb'; + + await freighter.mock({ isConnected: true, address: mockAddress }); + // Mock stealth address NOT existing so it creates the account + await horizon.mock({ + accountExists: false, + txSuccess: true, + txHash: 'tx_success_create_account', + }); + + await page.goto('/send'); + await page.selectOption('select', 'stellar'); + await page.getByRole('button', { name: 'Connect Freighter' }).click(); + + await page.getByPlaceholder('st:xlm:...').fill(recipientMetaAddress); + await page.getByPlaceholder('0.0').fill('50'); + await page.getByRole('button', { name: 'Send Privately' }).click(); + + // Success screen details + await expect(page.getByText('Transfer Complete')).toBeVisible(); + await expect(page.getByText('tx_success_create_account')).toBeVisible(); + await expect(page.getByRole('button', { name: 'New Transfer' })).toBeVisible(); + }); + + test('Send - 9. Successfully executes payment to an existing stealth address (regular payment)', async ({ + page, + freighter, + horizon, + }) => { + const mockAddress = 'GCDURJMLJBNVUVWXZ7UBXEIAEC4ONEWPWK6KDUUSDTUJJGXCSMBC2XHX'; + const recipientMetaAddress = + 'st:xlm:5a1922b5614eed2ef72ebad40abc5d014f7c27b6e1de5dc36976e9eec4cbe29e6b912a495f9f14513d54a00a7887f986d394a30a77239475caf211e8094b6cdb'; + + await freighter.mock({ isConnected: true, address: mockAddress }); + // Mock stealth address ALREADY existing, so it performs a payment + await horizon.mock({ + accountExists: true, + accountBalance: '20', + txSuccess: true, + txHash: 'tx_success_payment', + }); + + await page.goto('/send'); + await page.selectOption('select', 'stellar'); + await page.getByRole('button', { name: 'Connect Freighter' }).click(); + + await page.getByPlaceholder('st:xlm:...').fill(recipientMetaAddress); + await page.getByPlaceholder('0.0').fill('5'); + await page.getByRole('button', { name: 'Send Privately' }).click(); + + await expect(page.getByText('Transfer Complete')).toBeVisible(); + await expect(page.getByText('tx_success_payment')).toBeVisible(); + }); + + test('Receive - 10. Automatically derives keys and shows meta-address on wallet connection', async ({ + page, + freighter, + horizon, + }) => { + const mockAddress = 'GCDURJMLJBNVUVWXZ7UBXEIAEC4ONEWPWK6KDUUSDTUJJGXCSMBC2XHX'; + await freighter.mock({ isConnected: true, address: mockAddress }); + await horizon.mock({}); + + await page.goto('/receive'); + await page.selectOption('select', 'stellar'); + await page.getByRole('button', { name: 'Connect Freighter' }).click(); + + // Verify stealth meta-address card is visible + await expect(page.getByText('Your Stealth Meta-Address')).toBeVisible(); + // Verify it starts with st:xlm: prefix + const metaAddressElement = page.locator('code').first(); + await expect(metaAddressElement).toContainText('st:xlm:'); + }); + + test('Receive - 11. Copies the derived stealth meta-address to clipboard', async ({ + page, + freighter, + horizon, + browserName, + }) => { + // Clipboard reading via navigator.clipboard API is only standard/supported on Chromium in headless E2E + if (browserName !== 'chromium') { + test.skip(); + } + + const mockAddress = 'GCDURJMLJBNVUVWXZ7UBXEIAEC4ONEWPWK6KDUUSDTUJJGXCSMBC2XHX'; + await freighter.mock({ isConnected: true, address: mockAddress }); + await horizon.mock({}); + + await page.goto('/receive'); + await page.selectOption('select', 'stellar'); + await page.getByRole('button', { name: 'Connect Freighter' }).click(); + + // Click the copy button + await page.getByRole('button', { name: 'Copy' }).first().click(); + + // Verify clipboard content + const clipboardText = await page.evaluate(() => navigator.clipboard.readText()); + expect(clipboardText).toContain('st:xlm:'); + }); + + test('Receive - 12. Registers derived stealth keys on-chain', async ({ + page, + freighter, + horizon, + }) => { + const mockAddress = 'GCDURJMLJBNVUVWXZ7UBXEIAEC4ONEWPWK6KDUUSDTUJJGXCSMBC2XHX'; + await freighter.mock({ isConnected: true, address: mockAddress }); + // Mock as NOT registered initially, registration simulation succeeds + await horizon.mock({ accountExists: true, txSuccess: true, txHash: 'tx_register_hash' }); + + await page.goto('/receive'); + await page.selectOption('select', 'stellar'); + await page.getByRole('button', { name: 'Connect Freighter' }).click(); + + // Click Register On-Chain + await page.getByRole('button', { name: 'Register On-Chain' }).click(); + + // Verify registered indicator shows up + await expect(page.getByText('Meta-address registered on-chain')).toBeVisible(); + }); + + test('Receive - 13. Reports "No transfers found" when scanning yields empty events list', async ({ + page, + freighter, + horizon, + }) => { + const mockAddress = 'GCDURJMLJBNVUVWXZ7UBXEIAEC4ONEWPWK6KDUUSDTUJJGXCSMBC2XHX'; + await freighter.mock({ isConnected: true, address: mockAddress }); + // Empty events list + await horizon.mock({ sorobanEvents: [] }); + + await page.goto('/receive'); + await page.selectOption('select', 'stellar'); + await page.getByRole('button', { name: 'Connect Freighter' }).click(); + + await page.getByRole('button', { name: 'Scan for Payments' }).click(); + + await expect(page.getByText('No transfers found')).toBeVisible(); + await expect(page.getByText('No stealth transfers matched your keys.')).toBeVisible(); + }); + + test('Receive - 14. Successfully scans, displays matched payment transfers, and reveals keys', async ({ + page, + freighter, + horizon, + }) => { + const mockAddress = 'GCDURJMLJBNVUVWXZ7UBXEIAEC4ONEWPWK6KDUUSDTUJJGXCSMBC2XHX'; + // Matched stealth address details (must be a valid Stellar public key) + const matchedStealthAddress = 'GAL77LMANDOA32MTLU3GG3Z22G2543PDIE5REOEOIU5QL4VEYHJ5WKON'; + + await freighter.mock({ isConnected: true, address: mockAddress }); + + // Mock a Soroban Event matched to this viewing key + await horizon.mock({ + accountExists: true, + accountBalance: '100', + sorobanEvents: [ + { + schemeId: 1, + stealthAddress: matchedStealthAddress, + caller: mockAddress, + ephemeralPubKey: new Uint8Array(32), // dummy 32-byte ephemeral key + viewTag: 42, + }, + ], + }); + + await page.goto('/receive'); + await page.selectOption('select', 'stellar'); + await page.getByRole('button', { name: 'Connect Freighter' }).click(); + + await page.getByRole('button', { name: 'Scan for Payments' }).click(); + + // Verify detected payment + await expect(page.getByText('1 transfer found')).toBeVisible(); + await expect(page.getByText(matchedStealthAddress)).toBeVisible(); + await expect(page.getByText('100 XLM')).toBeVisible(); + + // Click "Reveal secret key" + await page.getByRole('button', { name: 'Reveal secret key' }).click(); + await expect(page.getByText('Stealth Key', { exact: true })).toBeVisible(); + }); + + test('Receive - 15. Successfully withdraws funds from a detected stealth payment', async ({ + page, + freighter, + horizon, + }) => { + const mockAddress = 'GCDURJMLJBNVUVWXZ7UBXEIAEC4ONEWPWK6KDUUSDTUJJGXCSMBC2XHX'; + const matchedStealthAddress = 'GAL77LMANDOA32MTLU3GG3Z22G2543PDIE5REOEOIU5QL4VEYHJ5WKON'; + + await freighter.mock({ isConnected: true, address: mockAddress }); + await horizon.mock({ + accountExists: true, + accountBalance: '250', + txSuccess: true, + txHash: 'tx_withdrawal_hash_987654', + sorobanEvents: [ + { + schemeId: 1, + stealthAddress: matchedStealthAddress, + caller: mockAddress, + ephemeralPubKey: new Uint8Array(32), + viewTag: 123, + }, + ], + }); + + await page.goto('/receive'); + await page.selectOption('select', 'stellar'); + await page.getByRole('button', { name: 'Connect Freighter' }).click(); + + await page.getByRole('button', { name: 'Scan for Payments' }).click(); + + // Fill destination and withdraw + await page.getByPlaceholder('Destination address (G...)').fill(mockAddress); + await page.getByRole('button', { name: 'Withdraw' }).click(); + + // Confirm withdrawal transaction success + await expect(page.getByText('Withdrawn —')).toBeVisible(); + await expect(page.getByText('tx_withdrawal')).toBeVisible(); + }); +}); diff --git a/package.json b/package.json index 886cfb3..7eb5400 100644 --- a/package.json +++ b/package.json @@ -9,7 +9,9 @@ "preview": "vite preview", "format": "prettier --write .", "format:check": "prettier --check .", - "prepare": "husky" + "prepare": "husky", + "test:e2e": "playwright test", + "test:e2e:ui": "playwright test --ui" }, "dependencies": { "@ckb-ccc/ccc": "^1.1.25", @@ -37,6 +39,8 @@ "devDependencies": { "@commitlint/cli": "^19.0.0", "@commitlint/config-conventional": "^19.0.0", + "@playwright/test": "^1.61.0", + "@types/node": "^26.0.0", "@types/react": "^19.0.0", "@types/react-dom": "^19.0.0", "@vitejs/plugin-react": "^4.5.0", diff --git a/playwright.config.ts b/playwright.config.ts new file mode 100644 index 0000000..7f1d75b --- /dev/null +++ b/playwright.config.ts @@ -0,0 +1,53 @@ +import { defineConfig, devices } from '@playwright/test'; + +/** + * See https://playwright.dev/docs/test-configuration. + */ +export default defineConfig({ + testDir: './e2e', + /* Run tests in files in parallel */ + fullyParallel: true, + /* Fail the build on CI if you accidentally left test.only in the source code. */ + forbidOnly: !!process.env.CI, + /* Retry on CI only to track and budget for flakes */ + retries: process.env.CI ? 2 : 0, + /* Opt out of parallel tests on CI. */ + workers: process.env.CI ? 1 : undefined, + /* Reporter to use. See https://playwright.dev/docs/test-reporters */ + reporter: 'html', + /* Shared settings for all the projects below. See https://playwright.dev/docs/api/class-testoptions. */ + use: { + /* Base URL to use in actions like `await page.goto('/')`. */ + baseURL: 'http://localhost:5173', + + /* Collect trace when retrying a failed test. See https://playwright.dev/docs/trace-viewer */ + trace: 'on-first-retry', + screenshot: 'only-on-failure', + video: 'retain-on-failure', + }, + + /* Configure projects for major browsers */ + projects: [ + { + name: 'chromium', + use: { ...devices['Desktop Chrome'] }, + }, + + { + name: 'firefox', + use: { ...devices['Desktop Firefox'] }, + }, + + { + name: 'webkit', + use: { ...devices['Desktop Safari'] }, + }, + ], + + /* Run your local dev server before starting the tests */ + webServer: { + command: 'pnpm dev', + url: 'http://localhost:5173', + reuseExistingServer: !process.env.CI, + }, +}); diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index e3b5c05..21150bf 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -74,10 +74,16 @@ importers: devDependencies: '@commitlint/cli': specifier: ^19.0.0 - version: 19.8.1(@types/node@25.6.0)(typescript@5.9.3) + version: 19.8.1(@types/node@26.0.0)(typescript@5.9.3) '@commitlint/config-conventional': specifier: ^19.0.0 version: 19.8.1 + '@playwright/test': + specifier: ^1.61.0 + version: 1.61.0 + '@types/node': + specifier: ^26.0.0 + version: 26.0.0 '@types/react': specifier: ^19.0.0 version: 19.2.14 @@ -86,7 +92,7 @@ importers: version: 19.2.3(@types/react@19.2.14) '@vitejs/plugin-react': specifier: ^4.5.0 - version: 4.7.0(vite@6.4.2(@types/node@25.6.0)(jiti@1.21.7)(terser@5.46.1)(yaml@2.8.3)) + version: 4.7.0(vite@6.4.2(@types/node@26.0.0)(jiti@1.21.7)(terser@5.46.1)(yaml@2.8.3)) autoprefixer: specifier: ^10.4.20 version: 10.5.0(postcss@8.5.9) @@ -107,7 +113,7 @@ importers: version: 5.9.3 vite: specifier: ^6.3.0 - version: 6.4.2(@types/node@25.6.0)(jiti@1.21.7)(terser@5.46.1)(yaml@2.8.3) + version: 6.4.2(@types/node@26.0.0)(jiti@1.21.7)(terser@5.46.1)(yaml@2.8.3) packages: @@ -732,6 +738,11 @@ packages: resolution: {integrity: sha512-IHnV6A+zxU7XwmKFinmYjUcwlyK9+xkG3/s9KcQhI9BjQKycrJ1JRO+FbNYPwZiPKW3je/DR0k7w8/gLa5eaxQ==} deprecated: 'The package is now available as "qr": npm install qr' + '@playwright/test@1.61.0': + resolution: {integrity: sha512-cKA5B6lpFEMyMGjxF54QihfYpB4FkEGH+qZhtArDEG+wezQAJY8Pq6C7T1SjWz+FFzt3TbyoXBQYk/0292TdJA==} + engines: {node: '>=18'} + hasBin: true + '@rainbow-me/rainbowkit@2.2.10': resolution: {integrity: sha512-8+E4die1A2ovN9t3lWxWnwqTGEdFqThXDQRj+E4eDKuUKyymYD+66Gzm6S9yfg8E95c6hmGlavGUfYPtl1EagA==} engines: {node: '>=12.4'} @@ -1596,6 +1607,9 @@ packages: '@types/node@25.6.0': resolution: {integrity: sha512-+qIYRKdNYJwY3vRCZMdJbPLJAtGjQBudzZzdzwQYkEPQd+PJGixUL5QfvCLDaULoLv+RhT3LDkwEfKaAkgSmNQ==} + '@types/node@26.0.0': + resolution: {integrity: sha512-vf2YFi1iY9lHGwNJMs01biZFbKJkrZR1T6/MlzjhJLPdntOHLhTrDSnSVcdtvjihi4VQNlrFRIxLsDBlQpAipA==} + '@types/react-dom@19.2.3': resolution: {integrity: sha512-jp2L/eY6fn+KgVVQAOqYItbF0VY/YApe5Mz2F0aykSO8gx31bYCZyvSeYxCHKvzHG5eZjc+zyaS5BrBWya2+kQ==} peerDependencies: @@ -2593,6 +2607,11 @@ packages: resolution: {integrity: sha512-zJ2mQYM18rEFOudeV4GShTGIQ7RbzA7ozbU9I/XBpm7kqgMywgmylMwXHxZJmkVoYkna9d2pVXVXPdYTP9ej8Q==} engines: {node: '>= 0.6'} + fsevents@2.3.2: + resolution: {integrity: sha512-xiqMQR4xAeHTuB9uWm+fFRcIOgKBMiOBP+eXiyT7jsgVCq1bkVygt00oASowB7EdtpOHaaPgKt812P9ab+DDKA==} + engines: {node: ^8.16.0 || ^10.6.0 || >=11.0.0} + os: [darwin] + fsevents@2.3.3: resolution: {integrity: sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw==} engines: {node: ^8.16.0 || ^10.6.0 || >=11.0.0} @@ -3387,6 +3406,16 @@ packages: resolution: {integrity: sha512-TfySrs/5nm8fQJDcBDuUng3VOUKsd7S+zqvbOTiGXHfxX4wK31ard+hoNuvkicM/2YFzlpDgABOevKSsB4G/FA==} engines: {node: '>= 6'} + playwright-core@1.61.0: + resolution: {integrity: sha512-caX7TrY3Ml6egyDX0WUcTHDxodl/b51y5wJOdCEA36QviK/s2g081hvmGs8eaE3DWb6NYZQ6BjO/QkNRPenoPA==} + engines: {node: '>=18'} + hasBin: true + + playwright@1.61.0: + resolution: {integrity: sha512-Z+7BeeqQPRRzklHsVFP4KTGIyMxKUmfeRA4WisM6G3/XW6nwGeX6fX9qYaDa+CiUqpOkb2f6X3nar05R3kSuJQ==} + engines: {node: '>=18'} + hasBin: true + pngjs@5.0.0: resolution: {integrity: sha512-40QW5YalBNfQo5yRYmiw7Yz6TKKVr3h6970B2YE+3fQpsWcrbj1PzJgxeJ19DRQjhMbKPIuMY8rFaXc8moolVw==} engines: {node: '>=10.13.0'} @@ -3995,6 +4024,9 @@ packages: undici-types@7.25.0: resolution: {integrity: sha512-AXNgS1Byr27fTI+2bsPEkV9CxkT8H6xNyRI68b3TatlZo3RkzlqQBLL+w7SmGPVpokjHbcuNVQUWE7FRTg+LRA==} + undici-types@8.3.0: + resolution: {integrity: sha512-j375ScV60dom+YkPFIfTLcOiPxkN/buHz5GobjLhixFuANaNs3C9l4GmrWqejgXWJ7BbJcFYpTEUkS1Ge8bpZQ==} + unicorn-magic@0.1.0: resolution: {integrity: sha512-lRfVq8fE8gz6QMBuDM6a+LO3IAzTi05H6gCVaUpir2E1Rwpo4ZUog45KpNXKC/Mn3Yb9UDuHumeFTo9iV/D9FQ==} engines: {node: '>=18'} @@ -4823,11 +4855,11 @@ snapshots: - utf-8-validate - zod - '@commitlint/cli@19.8.1(@types/node@25.6.0)(typescript@5.9.3)': + '@commitlint/cli@19.8.1(@types/node@26.0.0)(typescript@5.9.3)': dependencies: '@commitlint/format': 19.8.1 '@commitlint/lint': 19.8.1 - '@commitlint/load': 19.8.1(@types/node@25.6.0)(typescript@5.9.3) + '@commitlint/load': 19.8.1(@types/node@26.0.0)(typescript@5.9.3) '@commitlint/read': 19.8.1 '@commitlint/types': 19.8.1 tinyexec: 1.1.1 @@ -4874,7 +4906,7 @@ snapshots: '@commitlint/rules': 19.8.1 '@commitlint/types': 19.8.1 - '@commitlint/load@19.8.1(@types/node@25.6.0)(typescript@5.9.3)': + '@commitlint/load@19.8.1(@types/node@26.0.0)(typescript@5.9.3)': dependencies: '@commitlint/config-validator': 19.8.1 '@commitlint/execute-rule': 19.8.1 @@ -4882,7 +4914,7 @@ snapshots: '@commitlint/types': 19.8.1 chalk: 5.6.2 cosmiconfig: 9.0.1(typescript@5.9.3) - cosmiconfig-typescript-loader: 6.3.0(@types/node@25.6.0)(cosmiconfig@9.0.1(typescript@5.9.3))(typescript@5.9.3) + cosmiconfig-typescript-loader: 6.3.0(@types/node@26.0.0)(cosmiconfig@9.0.1(typescript@5.9.3))(typescript@5.9.3) lodash.isplainobject: 4.0.6 lodash.merge: 4.6.2 lodash.uniq: 4.5.0 @@ -5362,6 +5394,10 @@ snapshots: '@paulmillr/qr@0.2.1': {} + '@playwright/test@1.61.0': + dependencies: + playwright: 1.61.0 + '@rainbow-me/rainbowkit@2.2.10(@tanstack/react-query@5.99.0(react@19.2.5))(@types/react@19.2.14)(react-dom@19.2.5(react@19.2.5))(react@19.2.5)(typescript@5.9.3)(viem@2.47.17(bufferutil@4.1.0)(typescript@5.9.3)(utf-8-validate@6.0.6)(zod@4.3.6))(wagmi@2.19.5(@react-native-async-storage/async-storage@1.24.0(react-native@0.85.1(@babel/core@7.29.0)(@types/react@19.2.14)(bufferutil@4.1.0)(react@19.2.5)(utf-8-validate@6.0.6)))(@tanstack/query-core@5.99.0)(@tanstack/react-query@5.99.0(react@19.2.5))(@types/react@19.2.14)(bufferutil@4.1.0)(react-native@0.85.1(@babel/core@7.29.0)(@types/react@19.2.14)(bufferutil@4.1.0)(react@19.2.5)(utf-8-validate@6.0.6))(react@19.2.5)(typescript@5.9.3)(utf-8-validate@6.0.6)(viem@2.47.17(bufferutil@4.1.0)(typescript@5.9.3)(utf-8-validate@6.0.6)(zod@4.3.6))(zod@4.3.6))': dependencies: '@tanstack/react-query': 5.99.0(react@19.2.5) @@ -6581,11 +6617,11 @@ snapshots: '@types/connect@3.4.38': dependencies: - '@types/node': 25.6.0 + '@types/node': 26.0.0 '@types/conventional-commits-parser@5.0.2': dependencies: - '@types/node': 25.6.0 + '@types/node': 26.0.0 '@types/debug@4.1.13': dependencies: @@ -6617,6 +6653,10 @@ snapshots: dependencies: undici-types: 7.19.2 + '@types/node@26.0.0': + dependencies: + undici-types: 8.3.0 + '@types/react-dom@19.2.3(@types/react@19.2.14)': dependencies: '@types/react': 19.2.14 @@ -6631,11 +6671,11 @@ snapshots: '@types/ws@7.4.7': dependencies: - '@types/node': 25.6.0 + '@types/node': 26.0.0 '@types/ws@8.18.1': dependencies: - '@types/node': 25.6.0 + '@types/node': 26.0.0 '@types/yargs-parser@21.0.3': {} @@ -6670,7 +6710,7 @@ snapshots: dependencies: '@vanilla-extract/css': 1.17.3 - '@vitejs/plugin-react@4.7.0(vite@6.4.2(@types/node@25.6.0)(jiti@1.21.7)(terser@5.46.1)(yaml@2.8.3))': + '@vitejs/plugin-react@4.7.0(vite@6.4.2(@types/node@26.0.0)(jiti@1.21.7)(terser@5.46.1)(yaml@2.8.3))': dependencies: '@babel/core': 7.29.0 '@babel/plugin-transform-react-jsx-self': 7.27.1(@babel/core@7.29.0) @@ -6678,7 +6718,7 @@ snapshots: '@rolldown/pluginutils': 1.0.0-beta.27 '@types/babel__core': 7.20.5 react-refresh: 0.17.0 - vite: 6.4.2(@types/node@25.6.0)(jiti@1.21.7)(terser@5.46.1)(yaml@2.8.3) + vite: 6.4.2(@types/node@26.0.0)(jiti@1.21.7)(terser@5.46.1)(yaml@2.8.3) transitivePeerDependencies: - supports-color @@ -7701,9 +7741,9 @@ snapshots: core-util-is@1.0.3: {} - cosmiconfig-typescript-loader@6.3.0(@types/node@25.6.0)(cosmiconfig@9.0.1(typescript@5.9.3))(typescript@5.9.3): + cosmiconfig-typescript-loader@6.3.0(@types/node@26.0.0)(cosmiconfig@9.0.1(typescript@5.9.3))(typescript@5.9.3): dependencies: - '@types/node': 25.6.0 + '@types/node': 26.0.0 cosmiconfig: 9.0.1(typescript@5.9.3) jiti: 2.6.1 typescript: 5.9.3 @@ -8112,6 +8152,9 @@ snapshots: fresh@0.5.2: {} + fsevents@2.3.2: + optional: true + fsevents@2.3.3: optional: true @@ -9012,6 +9055,14 @@ snapshots: pirates@4.0.7: {} + playwright-core@1.61.0: {} + + playwright@1.61.0: + dependencies: + playwright-core: 1.61.0 + optionalDependencies: + fsevents: 2.3.2 + pngjs@5.0.0: {} pony-cause@2.1.11: {} @@ -9659,6 +9710,8 @@ snapshots: undici-types@7.25.0: {} + undici-types@8.3.0: {} + unicorn-magic@0.1.0: {} unpipe@1.0.0: {} @@ -9815,7 +9868,7 @@ snapshots: - utf-8-validate - zod - vite@6.4.2(@types/node@25.6.0)(jiti@1.21.7)(terser@5.46.1)(yaml@2.8.3): + vite@6.4.2(@types/node@26.0.0)(jiti@1.21.7)(terser@5.46.1)(yaml@2.8.3): dependencies: esbuild: 0.25.12 fdir: 6.5.0(picomatch@4.0.4) @@ -9824,7 +9877,7 @@ snapshots: rollup: 4.60.1 tinyglobby: 0.2.16 optionalDependencies: - '@types/node': 25.6.0 + '@types/node': 26.0.0 fsevents: 2.3.3 jiti: 1.21.7 terser: 5.46.1 diff --git a/src/components/StellarReceive.tsx b/src/components/StellarReceive.tsx index d279a52..3d91503 100644 --- a/src/components/StellarReceive.tsx +++ b/src/components/StellarReceive.tsx @@ -91,8 +91,8 @@ async function fetchAnnouncementEvents( try { const ann = parseAnnouncementEvent(event); if (ann) all.push(ann); - } catch { - // Skip malformed + } catch (err) { + console.error('Failed to parse announcement event:', err); } } @@ -270,11 +270,15 @@ function StellarStealthRow({ {!withdrawHash && balance && parseFloat(balance) > 0 && (
-