diff --git a/.github/workflows/e2e.yml b/.github/workflows/e2e.yml new file mode 100644 index 000000000..7ada0057b --- /dev/null +++ b/.github/workflows/e2e.yml @@ -0,0 +1,138 @@ +name: E2E + +on: + pull_request: + branches: + - main + types: + - opened + - synchronize + - reopened + workflow_dispatch: + +concurrency: + group: ${{ github.workflow }}-${{ github.ref }} + cancel-in-progress: true + +jobs: + e2e: + name: Playwright E2E (local stack) + runs-on: ubuntu-latest + timeout-minutes: 45 + env: + # Non-interactive OpenSSL subject values when local:certs generates the + # self-signed TLS certificates (see local/scripts/local-tools.sh). + HEADLESS: '1' + # The Traefik proxy serves a self-signed cert. In CI we don't wire the cert + # into the browser trust store, so tell Playwright to ignore HTTPS errors + # (honoured by e2e/playwright.config.ts). + PLAYWRIGHT_IGNORE_HTTPS_ERRORS: 'true' + # NODE_TLS_REJECT_UNAUTHORIZED=0 is intentionally NOT set here: at job + # scope it would also disable TLS verification for dependency downloads in + # `pnpm install` / `pnpm build`. It is set per-step only where Node must + # talk to the self-signed local stack. + steps: + - uses: actions/checkout@v5 + + - name: Setup Node, PNPM and dependencies + uses: ./.github/workflows/setup + + - name: Build all packages + run: pnpm build + + # docker compose is invoked with `--env-file ./local/.env.local + # --env-file ./local/.env` (see the `compose` script in package.json). + # .env.local is committed but .env is untracked, so create an empty one — + # every service default is baked in as ${VAR:-default} and points at the + # in-stack MockGatehub, so no secrets are required. + - name: Create local docker env file + run: touch local/.env + + # Brings up the full Docker stack (Postgres, Redis, Traefik, Rafiki, + # MockGatehub), adds /etc/hosts aliases, generates + trusts the TLS certs, + # and runs the Rafiki asset setup script. The Rafiki asset step + # (local/scripts/rafiki-setup.js) calls the self-signed HTTPS stack, so it + # needs TLS verification disabled. + - name: Bring up local stack + run: pnpm local:setup + env: + NODE_TLS_REJECT_UNAUTHORIZED: '0' + + # The wallet + boutique apps run on the host (not in Docker) and are what + # Traefik proxies testnet.test / api.testnet.test to. This mirrors the + # `dev` script minus its `pnpm local:up` step — the Docker stack is already + # up from local:setup, so we start only the four host processes. They read + # the committed packages/*/.env.local files. Run detached — the job + # teardown kills them when the runner is torn down. + - name: Start wallet and boutique apps + env: + NODE_TLS_REJECT_UNAUTHORIZED: '0' + run: | + mkdir -p "$RUNNER_TEMP/testnet-logs" + nohup pnpm exec concurrently \ + -n "WALLET-BE,WALLET-FE,BOUTIQUE-BE,BOUTIQUE-FE" \ + "pnpm wallet:backend dev" \ + "pnpm wallet:frontend dev" \ + "pnpm boutique:backend dev" \ + "pnpm boutique:frontend dev" \ + > "$RUNNER_TEMP/testnet-logs/dev.log" 2>&1 & + echo "Started host apps (pid $!)" + + - name: Wait for the stack to be ready + run: | + set -euo pipefail + + wait_for() { + local name="$1" cmd="$2" attempts="${3:-60}" + echo "Waiting for $name ..." + for i in $(seq 1 "$attempts"); do + if eval "$cmd" >/dev/null 2>&1; then + echo " $name is ready (after $((i * 5))s)" + return 0 + fi + sleep 5 + done + echo "::error::Timed out waiting for $name" + return 1 + } + + # Wallet backend listening on the host (direct HTTP port). + wait_for "wallet backend (localhost:3003)" \ + 'curl -s -o /dev/null http://localhost:3003' 60 + + # Wallet frontend served through Traefik at the e2e base URL. Next.js + # compiles the first requested page lazily, so poll for a 200. + wait_for "wallet frontend (https://testnet.test)" \ + '[ "$(curl -ks -o /dev/null -w "%{http_code}" https://testnet.test/auth/login)" = "200" ]' 60 + + - name: Install Playwright browsers + run: pnpm --filter @interledger/testnet-e2e exec playwright install --with-deps chromium + + # NODE_TLS_REJECT_UNAUTHORIZED=0 lets any Node-based test helpers reach the + # self-signed https://testnet.test endpoints (the browser itself is covered + # by PLAYWRIGHT_IGNORE_HTTPS_ERRORS). + - name: Run E2E tests + run: pnpm e2e:test + env: + NODE_TLS_REJECT_UNAUTHORIZED: '0' + + - name: Dump service logs on failure + if: failure() + run: | + echo "===== docker compose ps =====" + pnpm compose ps || true + echo "===== host apps (dev.log tail) =====" + tail -n 300 "$RUNNER_TEMP/testnet-logs/dev.log" || true + echo "===== docker compose logs (tail) =====" + pnpm compose logs --no-color --tail=200 || true + + - name: Upload Playwright report + if: always() + uses: actions/upload-artifact@v4 + with: + name: playwright-report + path: | + e2e/playwright-report + e2e/test-results + retention-days: 7 + if-no-files-found: ignore diff --git a/e2e/features/cross-currency-transfer.feature b/e2e/features/cross-currency-transfer.feature index bc1bbbbac..e3b91505e 100644 --- a/e2e/features/cross-currency-transfer.feature +++ b/e2e/features/cross-currency-transfer.feature @@ -10,3 +10,21 @@ Feature: Cross-currency payment transfers Then I should see the wallet address selector When I select a wallet address Then I should see the recipient address input field + + Scenario: User can send a cross-currency payment + Given I am a verified and logged-in wallet user + And I have a source wallet address configured backed by a EUR account + And I've deposited 100.00 EUR into my EUR account + And I have a second wallet address configured backed by a USD account + + When I navigate to the send page + And I select the EUR source account by wallet address + And I select the USD destination account by wallet address + And I enter a payment amount of 10.00 EUR + And I submit the payment + Then I should see a confirmation page with the payment details + Then I should see a success message indicating the payment was sent + When I navigate to the transactions page of my EUR account + Then I should see a new transaction with a debit of 10.00 EUR + When I navigate to the transactions page of my USD account + Then I should see a new transaction with a credit of the equivalent USD amount \ No newline at end of file diff --git a/e2e/features/steps/cross-currency-transfer.steps.ts b/e2e/features/steps/cross-currency-transfer.steps.ts index 92744b93d..267f2929f 100644 --- a/e2e/features/steps/cross-currency-transfer.steps.ts +++ b/e2e/features/steps/cross-currency-transfer.steps.ts @@ -1,10 +1,70 @@ import { expect } from '@playwright/test' +import type { Page } from '@playwright/test' import { completeLocalMockKyc, setupVerifiedUser } from '../../helpers/local-wallet' import { Given, Then, When } from './fixtures' +const EPSILON = 0.05 + +function parseAmountFromText(text: string): number { + const normalized = text.replace(/,/g, '').replace(/[^0-9.]/g, '') + const parsed = Number.parseFloat(normalized) + + if (Number.isNaN(parsed)) { + throw new Error(`Unable to parse amount from text: "${text}"`) + } + + return parsed +} + +type TransactionMatch = { + direction: 'debit' | 'credit' + amount: number +} + +/** + * Poll a per-account transactions page (reloading between attempts) until a row + * matching the given predicate appears. Cross-currency payments settle via ILP + * webhooks, so the transaction may not be present on first render. + */ +async function findTransaction( + page: Page, + predicate: (match: TransactionMatch) => boolean +): Promise { + // Cross-currency payments can take a while to settle; poll (reloading between + // attempts) until a matching row appears, checking after every reload. + for (let attempt = 0; attempt < 20; attempt++) { + if (attempt > 0) { + await page.waitForTimeout(3000) + await page.reload() + await expect(page).toHaveURL(/\/transactions/) + await expect( + page.getByRole('heading', { name: 'Transactions' }) + ).toBeVisible() + } + + const rows = page.locator('#transactionsList tbody tr.cursor-pointer') + const count = await rows.count() + + for (let i = 0; i < count; i++) { + const amountText = ( + await rows.nth(i).locator('td').nth(2).innerText() + ).trim() + const match: TransactionMatch = { + direction: amountText.startsWith('-') ? 'debit' : 'credit', + amount: parseAmountFromText(amountText) + } + if (predicate(match)) { + return match + } + } + } + + return null +} + Given('I am a verified and logged-in wallet user', async ({ page, flow }) => { // Use the helper to quickly set up a verified user const credentials = await setupVerifiedUser({ @@ -108,3 +168,334 @@ Then( await flow.takeScreenshot('amount-input-visible') } ) + +// --- Cross-currency payment scenario --- + +Given( + 'I have a source wallet address configured backed by a EUR account', + async ({ page, flow }) => { + // A managed user is provisioned with a default "EUR Account" and a wallet + // address on it during the verify/KYC flow, so we only need to locate it. + await page.goto('/') + await expect(page.getByRole('heading', { name: 'Accounts' })).toBeVisible() + + const eurAccount = page + .locator('a[href*="/account/"]') + .filter({ hasText: 'EUR Account' }) + .first() + + await expect(eurAccount).toBeVisible() + await eurAccount.click() + + await expect(page).toHaveURL(/\/account\/.+/) + await expect(page.getByRole('heading', { name: 'Balance' })).toBeVisible() + + const url = new URL(page.url()) + flow.eurAccountPath = `${url.pathname}${url.search}` + flow.eurAccountId = url.pathname.split('/account/')[1] + + // Confirm the default wallet address exists for this account. + const sourcePointer = page.locator('p.decoration-dashed').first() + await expect(sourcePointer).toBeVisible() + + await flow.takeScreenshot('eur-source-account-ready') + } +) + +Given( + "I've deposited 100.00 EUR into my EUR account", + async ({ page, flow }) => { + if (!flow.eurAccountPath) { + throw new Error('Missing EUR account path in flow state') + } + + await page.goto(flow.eurAccountPath) + await expect(page.locator('#fund')).toBeVisible() + await page.locator('#fund').click() + + await expect(page.getByText('Deposit to Account')).toBeVisible() + await page.getByLabel('Amount').fill('100.00') + await flow.takeScreenshot('eur-deposit-dialog-filled') + + await Promise.all([ + page.waitForResponse( + (response) => + response.url().includes('/fund') && + response.request().method() === 'POST' && + response.status() >= 200 && + response.status() < 300 + ), + page.locator('button[aria-label="deposit"]').click() + ]) + + await expect(page.getByText('Deposit success')).toBeVisible() + await flow.takeScreenshot('eur-deposit-success') + + // Let the deposit webhook settle before we start a payment against it. + await page.waitForTimeout(4000) + } +) + +Given( + 'I have a second wallet address configured backed by a USD account', + async ({ page, flow }) => { + // Create a USD account. + await page.goto('/account/create') + await expect( + page.getByRole('heading', { name: 'Create a new account' }) + ).toBeVisible() + + await page.getByLabel('Account name').fill('USD Account') + + // Open the asset react-select and pick USD. + const assetField = page + .locator('#createAccountForm div.space-y-1') + .filter({ has: page.locator('label', { hasText: 'Asset' }) }) + await assetField.click() + await page.getByRole('option', { name: 'USD', exact: true }).click() + await flow.takeScreenshot('usd-account-form-filled') + + await page.getByRole('button', { name: 'create account' }).click() + await expect(page.getByText('Account created.')).toBeVisible() + await flow.takeScreenshot('usd-account-created') + + await page.locator('#redirectButtonSuccess').click() + await expect(page).toHaveURL(/\/account\/.+/) + await expect(page.getByRole('heading', { name: 'Balance' })).toBeVisible() + + const url = new URL(page.url()) + flow.usdAccountPath = `${url.pathname}${url.search}` + flow.usdAccountId = url.pathname.split('/account/')[1] + + // Add a wallet address to the USD account. + await page.locator('#walletAddress').click() + await expect( + page.getByRole('heading', { name: 'Create Wallet Address' }) + ).toBeVisible() + + // Wallet address names allow only [a-z1-9_-] (note: no zero), so strip any + // disallowed characters (hyphens, zeros, non-hex) from the account id. + const suffix = (flow.usdAccountId ?? '') + .toLowerCase() + .replace(/[^a-z1-9]/g, '') + await page.getByLabel('Wallet Address name').fill(`usde2e${suffix}`) + await page.getByLabel('Public name').fill('USD E2E') + await flow.takeScreenshot('usd-wallet-address-form-filled') + + await page.getByRole('button', { name: 'create payment pointer' }).click() + + // Dialog closes and the page reloads showing the new wallet address. + await expect( + page.getByRole('heading', { name: 'Create Wallet Address' }) + ).toBeHidden() + + const usdPointer = page.locator('p.decoration-dashed').first() + await expect(usdPointer).toBeVisible() + flow.usdWalletAddressUrl = (await usdPointer.innerText()).trim() + + if (!flow.usdWalletAddressUrl) { + throw new Error('Failed to read USD wallet address URL') + } + + await flow.takeScreenshot('usd-wallet-address-ready') + } +) + +When( + 'I select the EUR source account by wallet address', + async ({ page, flow }) => { + const accountSelect = page.locator('#selectAccount') + await expect(accountSelect).toBeVisible() + await accountSelect.click() + + const eurOption = page + .locator('[role="option"]') + .filter({ hasText: '(EUR)' }) + .first() + await expect(eurOption).toBeVisible() + await eurOption.click() + await flow.takeScreenshot('send-eur-account-selected') + + const walletAddressSelect = page.locator('#selectWalletAddress') + await expect(walletAddressSelect).toBeVisible() + await walletAddressSelect.click() + + const firstWalletOption = page.locator('[role="option"]').first() + await expect(firstWalletOption).toBeVisible() + await firstWalletOption.click() + await flow.takeScreenshot('send-eur-wallet-address-selected') + } +) + +When( + 'I select the USD destination account by wallet address', + async ({ page, flow }) => { + if (!flow.usdWalletAddressUrl) { + throw new Error('Missing USD wallet address URL in flow state') + } + + const recipientInput = page.locator('#addRecipientWalletAddress') + await expect(recipientInput).toBeVisible() + await recipientInput.fill(flow.usdWalletAddressUrl) + + // The recipient field is debounced (1s) and then resolves the wallet + // address; wait for it to validate (no error shown). + await page.waitForTimeout(2000) + await expect( + page.getByText('Please check that the Wallet Address is correct') + ).toHaveCount(0) + await flow.takeScreenshot('send-usd-destination-entered') + } +) + +When('I enter a payment amount of 10.00 EUR', async ({ page, flow }) => { + // Switch from the default "receive" mode to "send" mode so the entered + // amount is the debit amount in the source (EUR) currency. + const toggle = page.locator('#sendReceive') + await expect(toggle).toBeVisible() + if ((await toggle.getAttribute('aria-checked')) === 'true') { + await toggle.click() + } + + const amountInput = page.locator('#addAmount') + await expect(amountInput).toBeVisible() + await amountInput.fill('10') + await flow.takeScreenshot('send-amount-entered') +}) + +When('I submit the payment', async ({ page, flow }) => { + await page.locator('button[aria-label="Pay"]').click() + + // The quote is fetched, then the confirmation dialog appears. + await expect(page.locator('#acceptQuote')).toBeVisible({ timeout: 30_000 }) + await flow.takeScreenshot('send-quote-dialog') +}) + +Then( + 'I should see a confirmation page with the payment details', + async ({ page, flow }) => { + const dialog = page + .locator('#acceptQuote') + .locator('xpath=ancestor::div[1]') + await expect(dialog).toBeVisible() + + const detailsText = await page + .getByText('You send:', { exact: false }) + .innerText() + + // Parse "You send: €10.00 / Recipient gets: $10.xx / Fee: ..." + const lines = detailsText + .split('\n') + .map((line) => line.trim()) + .filter(Boolean) + + const sendLine = lines.find((line) => line.startsWith('You send:')) + // The recipient line is " gets: $x.xx" (falls back to + // "Recipient gets:" when no public name is resolved). + const receiveLine = lines.find((line) => line.includes('gets:')) + + if (!sendLine || !receiveLine) { + throw new Error(`Unexpected quote details: "${detailsText}"`) + } + + // Parse only the amount after the label — the receiver's public name (which + // precedes "gets:") can itself contain digits. + flow.expectedDebitAmount = parseAmountFromText(sendLine.split(':').pop()!) + flow.expectedReceiveAmount = parseAmountFromText( + receiveLine.slice(receiveLine.indexOf('gets:') + 'gets:'.length) + ) + + expect(flow.expectedDebitAmount).toBeCloseTo(10, 2) + expect(flow.expectedReceiveAmount).toBeGreaterThan(0) + + await flow.takeScreenshot('send-confirmation-details') + } +) + +Then( + 'I should see a success message indicating the payment was sent', + async ({ page, flow }) => { + // Accept the quote to actually send the payment. + await page.locator('#acceptQuote').click() + + await expect(page.getByText('Money was successfully sent.')).toBeVisible({ + timeout: 30_000 + }) + await flow.takeScreenshot('send-success-message') + } +) + +When( + 'I navigate to the transactions page of my EUR account', + async ({ page, flow }) => { + if (!flow.eurAccountId) { + throw new Error('Missing EUR account id in flow state') + } + + await page.goto(`/transactions?accountId=${flow.eurAccountId}`) + await expect(page).toHaveURL(/\/transactions/) + await expect( + page.getByRole('heading', { name: 'Transactions' }) + ).toBeVisible() + await flow.takeScreenshot('eur-transactions-page') + } +) + +Then( + 'I should see a new transaction with a debit of 10.00 EUR', + async ({ page, flow }) => { + const match = await findTransaction( + page, + (m) => m.direction === 'debit' && Math.abs(m.amount - 10) <= EPSILON + ) + + await flow.takeScreenshot('eur-debit-transaction') + + expect( + match, + 'Expected an outgoing transaction of 10.00 EUR on the EUR account' + ).not.toBeNull() + } +) + +When( + 'I navigate to the transactions page of my USD account', + async ({ page, flow }) => { + if (!flow.usdAccountId) { + throw new Error('Missing USD account id in flow state') + } + + await page.goto(`/transactions?accountId=${flow.usdAccountId}`) + await expect(page).toHaveURL(/\/transactions/) + await expect( + page.getByRole('heading', { name: 'Transactions' }) + ).toBeVisible() + await flow.takeScreenshot('usd-transactions-page') + } +) + +Then( + 'I should see a new transaction with a credit of the equivalent USD amount', + async ({ page, flow }) => { + const expected = flow.expectedReceiveAmount + + const match = await findTransaction(page, (m) => { + if (m.direction !== 'credit') { + return false + } + if (expected === undefined) { + return m.amount > 0 + } + return Math.abs(m.amount - expected) <= EPSILON + }) + + await flow.takeScreenshot('usd-credit-transaction') + + expect( + match, + `Expected an incoming USD credit${ + expected !== undefined ? ` of ~${expected.toFixed(2)}` : '' + } on the USD account` + ).not.toBeNull() + } +) diff --git a/e2e/features/steps/fixtures.ts b/e2e/features/steps/fixtures.ts index 7278bf916..375ca16a0 100644 --- a/e2e/features/steps/fixtures.ts +++ b/e2e/features/steps/fixtures.ts @@ -16,6 +16,14 @@ type FlowState = { postDepositTransactionRows?: number delayedRefreshTransactionRows?: number latestTransactionAmount?: number + // Cross-currency transfer state + eurAccountId?: string + eurAccountPath?: string + usdAccountId?: string + usdAccountPath?: string + usdWalletAddressUrl?: string + expectedDebitAmount?: number + expectedReceiveAmount?: number featureName: string takeScreenshot: (name: string) => Promise }