diff --git a/e2e/.env.example b/e2e/.env.example new file mode 100644 index 000000000..c3d9c7afa --- /dev/null +++ b/e2e/.env.example @@ -0,0 +1,3 @@ +TEST_BASE_URL=http://localhost:4003 +WALLET_BACKEND_CONTAINER=wallet-backend-local +ENABLE_SCREENSHOTS=false \ No newline at end of file diff --git a/e2e/.gitignore b/e2e/.gitignore new file mode 100644 index 000000000..59f917a58 --- /dev/null +++ b/e2e/.gitignore @@ -0,0 +1,5 @@ +.env +node_modules +playwright-report +test-results +.features-gen \ No newline at end of file diff --git a/e2e/features/auth-signup-dashboard.feature b/e2e/features/auth-signup-dashboard.feature new file mode 100644 index 000000000..86d137f75 --- /dev/null +++ b/e2e/features/auth-signup-dashboard.feature @@ -0,0 +1,19 @@ +Feature: Wallet authentication onboarding + As a new wallet user + I want to sign up, verify my email, complete KYC, and reach my default account + So that I can access my wallet dashboard + + Scenario: New user completes signup, verification, login, KYC, and account access + Given I am a new unique wallet user + When I open the signup page + And I complete the signup form + And I submit signup + Then I should see signup confirmation + When I open the verification link from backend logs + Then I should see verification success + When I continue to login + And I login with my new credentials + And I complete KYC if I am redirected to KYC + Then I should see the accounts dashboard + When I open the EUR default account + Then I should see the account balance page diff --git a/e2e/features/cross-currency-transfer.feature b/e2e/features/cross-currency-transfer.feature new file mode 100644 index 000000000..bc1bbbbac --- /dev/null +++ b/e2e/features/cross-currency-transfer.feature @@ -0,0 +1,12 @@ +Feature: Cross-currency payment transfers + As a wallet user + I want to send payments between accounts in different currencies + So that I can transfer value across currency boundaries + + Scenario: User can navigate to send page and select accounts + Given I am a verified and logged-in wallet user + When I navigate to the send page + And I select a source account + Then I should see the wallet address selector + When I select a wallet address + Then I should see the recipient address input field diff --git a/e2e/features/steps/auth-signup-dashboard.steps.ts b/e2e/features/steps/auth-signup-dashboard.steps.ts new file mode 100644 index 000000000..48c392c94 --- /dev/null +++ b/e2e/features/steps/auth-signup-dashboard.steps.ts @@ -0,0 +1,146 @@ +import { expect } from '@playwright/test' +import { + completeLocalMockKyc, + waitForVerificationLinkFromLogs +} from '../../helpers/local-wallet' +import { Given, Then, When } from './fixtures' + +Given('I am a new unique wallet user', async ({ flow }) => { + expect(flow.credentials.email).toContain('e2e-') + expect(flow.credentials.password).toContain('Testnet!') +}) + +When('I open the signup page', async ({ page, flow }) => { + await page.goto('/auth/signup') + await expect( + page.getByRole('heading', { name: 'Create Account' }) + ).toBeVisible() + await flow.takeScreenshot('signup-page') +}) + +When('I complete the signup form', async ({ page, flow }) => { + const signUpForm = page.locator('form') + + await signUpForm + .getByLabel('E-mail *', { exact: true }) + .fill(flow.credentials.email) + await flow.takeScreenshot('signup-email-filled') + await signUpForm + .getByLabel('Password *', { exact: true }) + .fill(flow.credentials.password) + await flow.takeScreenshot('signup-password-filled') + await signUpForm + .getByLabel('Confirm password *', { exact: true }) + .fill(flow.credentials.password) + await flow.takeScreenshot('signup-confirm-password-filled') +}) + +When('I submit signup', async ({ page, flow }) => { + const signUpForm = page.locator('form') + + await Promise.all([ + page.waitForResponse( + (response) => + response.url().endsWith('/signup') && + response.request().method() === 'POST' && + response.status() === 201 + ), + signUpForm.locator('button[type="submit"]').click() + ]) + + await flow.takeScreenshot('signup-submitted') +}) + +Then('I should see signup confirmation', async ({ page, flow }) => { + await expect( + page.getByText('A verification link has been sent to your email account.') + ).toBeVisible() + await flow.takeScreenshot('signup-success') +}) + +When( + 'I open the verification link from backend logs', + async ({ page, flow }) => { + const verificationLink = await waitForVerificationLinkFromLogs({ + since: flow.logMarker, + containerName: flow.containerName + }) + + flow.verificationLink = verificationLink + + await page.goto(verificationLink) + await flow.takeScreenshot('verification-page-opened') + } +) + +Then('I should see verification success', async ({ page, flow }) => { + await expect( + page.getByText( + 'Your email has been verified. Continue to login to use Interledger Test Wallet.' + ) + ).toBeVisible() + await flow.takeScreenshot('verify-success') +}) + +When('I continue to login', async ({ page, flow }) => { + await page.locator('a[href="/auth/login"]').first().click() + await expect(page).toHaveURL(/\/auth\/login$/) + await flow.takeScreenshot('login-page-opened') +}) + +When('I login with my new credentials', async ({ page, flow }) => { + const loginForm = page.locator('form') + + await loginForm + .getByLabel('E-mail *', { exact: true }) + .fill(flow.credentials.email) + await flow.takeScreenshot('login-email-filled') + await loginForm + .getByLabel('Password *', { exact: true }) + .fill(flow.credentials.password) + await flow.takeScreenshot('login-password-filled') + + await Promise.all([ + page.waitForResponse( + (response) => + response.url().endsWith('/login') && + response.request().method() === 'POST' && + response.status() === 200 + ), + loginForm.locator('button[type="submit"]').click() + ]) + + await flow.takeScreenshot('login-submitted') + await page.waitForURL(/\/(kyc)?$/, { timeout: 60_000 }) + await flow.takeScreenshot('post-login') +}) + +When('I complete KYC if I am redirected to KYC', async ({ page, flow }) => { + if (page.url().endsWith('/kyc')) { + await completeLocalMockKyc(page, flow.takeScreenshot) + } +}) + +Then('I should see the accounts dashboard', async ({ page, flow }) => { + await expect(page.getByRole('heading', { name: 'Accounts' })).toBeVisible() + await expect(page.getByText('Here is your account overview!')).toBeVisible() + await flow.takeScreenshot('dashboard-confirmed') +}) + +When('I open the EUR default account', async ({ page, flow }) => { + const defaultAccount = page + .locator('a[href*="/account/"]') + .filter({ hasText: 'EUR Account' }) + .first() + + await expect(defaultAccount).toBeVisible() + await flow.takeScreenshot('dashboard') + await defaultAccount.click() + await flow.takeScreenshot('default-account-opened') +}) + +Then('I should see the account balance page', async ({ page, flow }) => { + await expect(page).toHaveURL(/\/account\/.+/) + await expect(page.getByRole('heading', { name: 'Balance' })).toBeVisible() + await flow.takeScreenshot('account-page') +}) diff --git a/e2e/features/steps/cross-currency-transfer.steps.ts b/e2e/features/steps/cross-currency-transfer.steps.ts new file mode 100644 index 000000000..ee62c3a65 --- /dev/null +++ b/e2e/features/steps/cross-currency-transfer.steps.ts @@ -0,0 +1,77 @@ +import { expect } from '@playwright/test' +import { setupVerifiedUser } from '../../helpers/local-wallet' +import { Given, Then, When } from './fixtures' + +Given('I am a verified and logged-in wallet user', async ({ page, flow }) => { + const containerName = flow.containerName + + // Use the helper to quickly set up a verified user + const credentials = await setupVerifiedUser({ + page, + takeScreenshot: flow.takeScreenshot, + containerName, + skipScreenshots: false + }) + + // Store credentials in flow for later use if needed + flow.credentials = credentials + await flow.takeScreenshot('verified-user-ready') +}) + +When('I navigate to the send page', async ({ page, flow }) => { + await page.goto('/send') + await expect(page).toHaveURL(/\/send$/) + await expect(page.getByRole('heading', { name: 'Send' })).toBeVisible() + await flow.takeScreenshot('send-page-loaded') +}) + +When('I select a source account', async ({ page, flow }) => { + // Click on the account selector + const accountSelect = page.locator('#selectAccount') + await expect(accountSelect).toBeVisible() + await flow.takeScreenshot('before-select-account') + + await accountSelect.click() + await flow.takeScreenshot('account-dropdown-opened') + + // Select the first account (EUR Account or whatever is available) + const firstAccountOption = page.locator('[role="option"]').first() + await expect(firstAccountOption).toBeVisible() + await firstAccountOption.click() + await flow.takeScreenshot('account-selected') +}) + +Then('I should see the wallet address selector', async ({ page, flow }) => { + const walletAddressSelect = page.locator('#selectWalletAddress') + await expect(walletAddressSelect).toBeVisible() + await flow.takeScreenshot('wallet-address-selector-visible') +}) + +When('I select a wallet address', async ({ page, flow }) => { + const walletAddressSelect = page.locator('#selectWalletAddress') + await expect(walletAddressSelect).toBeVisible() + await flow.takeScreenshot('before-select-wallet-address') + + await walletAddressSelect.click() + await flow.takeScreenshot('wallet-address-dropdown-opened') + + // Select the first wallet address option + const firstWalletOption = page.locator('[role="option"]').first() + await expect(firstWalletOption).toBeVisible() + await firstWalletOption.click() + await flow.takeScreenshot('wallet-address-selected') +}) + +Then( + 'I should see the recipient address input field', + async ({ page, flow }) => { + const recipientInput = page.locator('#addRecipientWalletAddress') + await expect(recipientInput).toBeVisible() + await flow.takeScreenshot('recipient-address-input-visible') + + // Verify amount input is also visible + const amountInput = page.locator('#addAmount') + await expect(amountInput).toBeVisible() + await flow.takeScreenshot('amount-input-visible') + } +) diff --git a/e2e/features/steps/fixtures.ts b/e2e/features/steps/fixtures.ts new file mode 100644 index 000000000..d40e6bee8 --- /dev/null +++ b/e2e/features/steps/fixtures.ts @@ -0,0 +1,53 @@ +import { createBdd, test as base } from 'playwright-bdd' +import { + type Credentials, + createUniqueCredentials +} from '../../helpers/local-wallet' +import { mkdir } from 'node:fs/promises' + +type FlowState = { + credentials: Credentials + logMarker: Date + containerName: string + screenshotCounter: number + verificationLink?: string + featureName: string + takeScreenshot: (name: string) => Promise +} + +export const test = base.extend<{ flow: FlowState }>({ + flow: async ({ page }, use, testInfo) => { + // Extract feature name from the generated test file path + // e.g., ".features-gen/auth-signup-dashboard.feature.spec.js" → "auth-signup-dashboard" + const testFile = testInfo.file + const fileName = testFile.split('/').pop() || 'unknown' + const featureName = fileName + .replace('.feature.spec.js', '') + .replace('.feature.spec.ts', '') + .replace('.spec.js', '') + .replace('.spec.ts', '') + + const state: FlowState = { + credentials: createUniqueCredentials(), + logMarker: new Date(), + containerName: + process.env.WALLET_BACKEND_CONTAINER || 'wallet-backend-local', + screenshotCounter: 0, + featureName, + takeScreenshot: async (name: string) => { + state.screenshotCounter += 1 + const screenshotDir = `test-results/${featureName}` + await mkdir(screenshotDir, { recursive: true }) + await page.screenshot({ + path: `${screenshotDir}/${String(state.screenshotCounter).padStart(3, '0')}-${name}.png`, + fullPage: true + }) + } + } + + // eslint-disable-next-line react-hooks/rules-of-hooks + await use(state) + } +}) + +export const { Given, When, Then } = createBdd(test) diff --git a/e2e/helpers/local-wallet.ts b/e2e/helpers/local-wallet.ts new file mode 100644 index 000000000..fd2a645e4 --- /dev/null +++ b/e2e/helpers/local-wallet.ts @@ -0,0 +1,219 @@ +import { expect, Page } from '@playwright/test' +import { execFile } from 'node:child_process' +import { promisify } from 'node:util' + +const execFileAsync = promisify(execFile) + +type ScreenshotFn = (name: string) => Promise + +export type Credentials = { + email: string + password: string +} + +export function createUniqueCredentials(): Credentials { + const suffix = `${Date.now()}-${Math.floor(Math.random() * 100000)}` + + return { + email: `e2e-${suffix}@ilp.com`, + password: `Testnet!${suffix}Aa` + } +} + +export async function waitForVerificationLinkFromLogs(args: { + since: Date + containerName?: string + timeoutMs?: number + pollIntervalMs?: number +}): Promise { + const containerName = args.containerName || 'wallet-backend-local' + const timeoutMs = args.timeoutMs ?? 30_000 + const pollIntervalMs = args.pollIntervalMs ?? 1_000 + const deadline = Date.now() + timeoutMs + const linkPattern = + /Verify email link is:\s+(https?:\/\/\S+\/auth\/verify\/[a-f0-9]+)/g + + while (Date.now() < deadline) { + let output = '' + + try { + const result = await execFileAsync( + 'docker', + [ + 'logs', + '--since', + args.since.toISOString(), + '--timestamps', + containerName + ], + { maxBuffer: 1024 * 1024 } + ) + + output = `${result.stdout}\n${result.stderr}` + } catch (error) { + const execError = error as NodeJS.ErrnoException & { + stdout?: string + stderr?: string + } + + if (execError.code === 'ENOENT') { + throw new Error( + 'docker CLI is required to retrieve local verification links' + ) + } + + output = `${execError.stdout ?? ''}\n${execError.stderr ?? ''}` + } + + const matches = [...output.matchAll(linkPattern)] + const latestMatch = matches.at(-1)?.[1] + + if (latestMatch) { + return latestMatch + } + + await new Promise((resolve) => setTimeout(resolve, pollIntervalMs)) + } + + throw new Error( + `Timed out waiting for a verification link in docker logs for container ${containerName}` + ) +} + +export async function completeLocalMockKyc( + page: Page, + takeScreenshot: ScreenshotFn +): Promise { + await expect(page).toHaveURL(/\/kyc$/) + await takeScreenshot('kyc-page-loaded') + + const frame = page.frameLocator('iframe') + + await frame.getByLabel('First Name').fill('E2E') + await takeScreenshot('kyc-first-name-filled') + await frame.getByLabel('Last Name').fill('User') + await takeScreenshot('kyc-last-name-filled') + await frame.getByLabel('Date of Birth').fill('1990-01-01') + await takeScreenshot('kyc-date-of-birth-filled') + await frame.getByLabel('Address').fill('1 Test Lane') + await takeScreenshot('kyc-address-filled') + await frame.getByLabel('City').fill('Basel') + await takeScreenshot('kyc-city-filled') + await frame.getByLabel('Country').fill('Switzerland') + await takeScreenshot('kyc-country-filled') + + await Promise.all([ + page.waitForURL(/\/$/, { timeout: 60_000 }), + frame.locator('#submitBtn').click() + ]) + + await takeScreenshot('kyc-submitted') + await expect(page.getByRole('heading', { name: 'Accounts' })).toBeVisible() + await takeScreenshot('kyc-dashboard-visible') +} + +/** + * Complete the full signup, email verification, login, and KYC flow for a test user. + * Returns the credentials used so they can be reused for API calls if needed. + * Leaves the user logged in on the dashboard. + */ +export async function setupVerifiedUser(args: { + page: Page + takeScreenshot: (name: string) => Promise + containerName: string + skipScreenshots?: boolean +}): Promise { + const { page, takeScreenshot, containerName, skipScreenshots = false } = args + const credentials = createUniqueCredentials() + const logMarker = new Date() + + const ss = skipScreenshots ? async () => {} : takeScreenshot + + // Signup + await page.goto('/auth/signup') + await ss('001-signup-page') + const signUpForm = page.locator('form') + await signUpForm + .getByLabel('E-mail *', { exact: true }) + .fill(credentials.email) + await ss('002-signup-email-filled') + await signUpForm + .getByLabel('Password *', { exact: true }) + .fill(credentials.password) + await ss('003-signup-password-filled') + await signUpForm + .getByLabel('Confirm password *', { exact: true }) + .fill(credentials.password) + await ss('004-signup-confirm-password-filled') + + await Promise.all([ + page.waitForResponse( + (response) => + response.url().endsWith('/signup') && + response.request().method() === 'POST' && + response.status() === 201 + ), + signUpForm.locator('button[type="submit"]').click() + ]) + await ss('005-signup-submitted') + + await expect( + page.getByText('A verification link has been sent to your email account.') + ).toBeVisible() + await ss('006-signup-success') + + // Verify email + const verificationLink = await waitForVerificationLinkFromLogs({ + since: logMarker, + containerName + }) + + await page.goto(verificationLink) + await ss('007-verification-page-opened') + await expect( + page.getByText( + 'Your email has been verified. Continue to login to use Interledger Test Wallet.' + ) + ).toBeVisible() + await ss('008-verify-success') + + // Login + await page.locator('a[href="/auth/login"]').first().click() + await expect(page).toHaveURL(/\/auth\/login$/) + await ss('009-login-page-opened') + + const loginForm = page.locator('form') + await loginForm + .getByLabel('E-mail *', { exact: true }) + .fill(credentials.email) + await ss('010-login-email-filled') + await loginForm + .getByLabel('Password *', { exact: true }) + .fill(credentials.password) + await ss('011-login-password-filled') + + await Promise.all([ + page.waitForResponse( + (response) => + response.url().endsWith('/login') && + response.request().method() === 'POST' && + response.status() === 200 + ), + loginForm.locator('button[type="submit"]').click() + ]) + await ss('012-login-submitted') + + await page.waitForURL(/(\/)?( kyc)?$/, { timeout: 60_000 }) + await ss('013-post-login') + + // KYC if needed + if (page.url().endsWith('/kyc')) { + await completeLocalMockKyc(page, ss) + } + + // Verify we're on dashboard + await expect(page.getByRole('heading', { name: 'Accounts' })).toBeVisible() + await ss('014-dashboard-ready') + + return credentials +} diff --git a/e2e/package.json b/e2e/package.json new file mode 100644 index 000000000..a0cbd9249 --- /dev/null +++ b/e2e/package.json @@ -0,0 +1,18 @@ +{ + "name": "@interledger/testnet-e2e", + "private": true, + "packageManager": "pnpm@9.1.4", + "scripts": { + "generate": "bddgen", + "test": "bddgen && playwright test", + "test:headed": "bddgen && playwright test --headed", + "test:debug": "bddgen && playwright test --debug" + }, + "devDependencies": { + "@playwright/test": "^1.56.0", + "@types/node": "^20.17.30", + "dotenv": "^17.2.3", + "playwright-bdd": "^8.0.0", + "typescript": "^5.9.3" + } +} diff --git a/e2e/playwright.config.ts b/e2e/playwright.config.ts new file mode 100644 index 000000000..4ca510025 --- /dev/null +++ b/e2e/playwright.config.ts @@ -0,0 +1,38 @@ +import { defineConfig, devices } from '@playwright/test' +import { defineBddConfig } from 'playwright-bdd' +import dotenv from 'dotenv' +import path from 'path' + +dotenv.config({ path: path.resolve(__dirname, '.env') }) + +const testDir = defineBddConfig({ + paths: ['features/**/*.feature'], + require: ['features/steps/**/*.ts'] +}) + +export default defineConfig({ + testDir, + fullyParallel: false, + forbidOnly: !!process.env.CI, + retries: process.env.CI ? 1 : 0, + workers: 1, + reporter: [['list'], ['html', { open: 'never' }]], + timeout: 3 * 60 * 1000, + expect: { + timeout: 15 * 1000 + }, + use: { + baseURL: process.env.TEST_BASE_URL || 'http://localhost:4003', + trace: 'on-first-retry', + screenshot: 'only-on-failure' + }, + projects: [ + { + name: 'chromium', + use: { + ...devices['Desktop Chrome'], + viewport: { width: 1440, height: 1080 } + } + } + ] +}) diff --git a/e2e/tsconfig.json b/e2e/tsconfig.json new file mode 100644 index 000000000..38517371e --- /dev/null +++ b/e2e/tsconfig.json @@ -0,0 +1,18 @@ +{ + "compilerOptions": { + "target": "ES2022", + "module": "CommonJS", + "moduleResolution": "Node", + "types": ["node", "@playwright/test"], + "esModuleInterop": true, + "strict": true, + "skipLibCheck": true, + "noEmit": true + }, + "include": [ + "helpers/**/*.ts", + "features/**/*.ts", + "tests/**/*.ts", + "playwright.config.ts" + ] +}