From 03869c4cb613fbac47e879eebecaa194c6c1b828 Mon Sep 17 00:00:00 2001 From: Stephan Butler Date: Tue, 24 Mar 2026 17:16:56 +0200 Subject: [PATCH 1/3] test: e2e tests for basic functionality --- e2e/.env.example | 3 + e2e/.gitignore | 5 + e2e/features/auth-signup-dashboard.feature | 19 ++ e2e/features/cross-currency-transfer.feature | 12 + .../steps/auth-signup-dashboard.steps.ts | 146 ++++++++++++ .../steps/cross-currency-transfer.steps.ts | 77 ++++++ e2e/features/steps/fixtures.ts | 53 +++++ e2e/helpers/local-wallet.ts | 219 ++++++++++++++++++ e2e/package.json | 18 ++ e2e/playwright.config.ts | 38 +++ e2e/tsconfig.json | 18 ++ 11 files changed, 608 insertions(+) create mode 100644 e2e/.env.example create mode 100644 e2e/.gitignore create mode 100644 e2e/features/auth-signup-dashboard.feature create mode 100644 e2e/features/cross-currency-transfer.feature create mode 100644 e2e/features/steps/auth-signup-dashboard.steps.ts create mode 100644 e2e/features/steps/cross-currency-transfer.steps.ts create mode 100644 e2e/features/steps/fixtures.ts create mode 100644 e2e/helpers/local-wallet.ts create mode 100644 e2e/package.json create mode 100644 e2e/playwright.config.ts create mode 100644 e2e/tsconfig.json 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" + ] +} From 556d45583ed4df61255c7d68c2e73e7ca46d65ed Mon Sep 17 00:00:00 2001 From: Stephan Butler Date: Sun, 5 Apr 2026 19:26:02 +0200 Subject: [PATCH 2/3] test: e2e tests now pointing to correct URLs --- e2e/.env.example | 6 ++-- .../steps/cross-currency-transfer.steps.ts | 35 ++++++++++++++++++- e2e/helpers/local-wallet.ts | 2 +- e2e/playwright.config.ts | 8 ++++- 4 files changed, 46 insertions(+), 5 deletions(-) diff --git a/e2e/.env.example b/e2e/.env.example index c3d9c7afa..cd8ecbbb0 100644 --- a/e2e/.env.example +++ b/e2e/.env.example @@ -1,3 +1,5 @@ -TEST_BASE_URL=http://localhost:4003 +TEST_BASE_URL=https://testnet.test WALLET_BACKEND_CONTAINER=wallet-backend-local -ENABLE_SCREENSHOTS=false \ No newline at end of file +ENABLE_SCREENSHOTS=false +# Set to true only if your local cert trust is not configured. +PLAYWRIGHT_IGNORE_HTTPS_ERRORS=true \ 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 ee62c3a65..aeffe6675 100644 --- a/e2e/features/steps/cross-currency-transfer.steps.ts +++ b/e2e/features/steps/cross-currency-transfer.steps.ts @@ -1,5 +1,5 @@ import { expect } from '@playwright/test' -import { setupVerifiedUser } from '../../helpers/local-wallet' +import { completeLocalMockKyc, setupVerifiedUser } from '../../helpers/local-wallet' import { Given, Then, When } from './fixtures' Given('I am a verified and logged-in wallet user', async ({ page, flow }) => { @@ -15,6 +15,39 @@ Given('I am a verified and logged-in wallet user', async ({ page, flow }) => { // Store credentials in flow for later use if needed flow.credentials = credentials + + // Validate authenticated access to protected routes; recover by logging in again if needed. + await page.goto('/send') + + if (page.url().includes('/auth/login')) { + const loginForm = page.locator('form') + + await loginForm + .getByLabel('E-mail *', { exact: true }) + .fill(credentials.email) + await loginForm + .getByLabel('Password *', { exact: true }) + .fill(credentials.password) + + await Promise.all([ + page.waitForResponse( + (response) => + response.url().endsWith('/login') && + response.request().method() === 'POST' && + response.status() === 200 + ), + loginForm.locator('button[type="submit"]').click() + ]) + + if (page.url().endsWith('/kyc')) { + await completeLocalMockKyc(page, flow.takeScreenshot) + await page.goto('/send') + } else { + await page.waitForURL(/\/send$/, { timeout: 60_000 }) + } + } + + await expect(page).toHaveURL(/\/send$/) await flow.takeScreenshot('verified-user-ready') }) diff --git a/e2e/helpers/local-wallet.ts b/e2e/helpers/local-wallet.ts index fd2a645e4..6292f440a 100644 --- a/e2e/helpers/local-wallet.ts +++ b/e2e/helpers/local-wallet.ts @@ -203,7 +203,7 @@ export async function setupVerifiedUser(args: { ]) await ss('012-login-submitted') - await page.waitForURL(/(\/)?( kyc)?$/, { timeout: 60_000 }) + await page.waitForURL(/\/(kyc)?$/, { timeout: 60_000 }) await ss('013-post-login') // KYC if needed diff --git a/e2e/playwright.config.ts b/e2e/playwright.config.ts index 4ca510025..d4a67abeb 100644 --- a/e2e/playwright.config.ts +++ b/e2e/playwright.config.ts @@ -5,6 +5,11 @@ import path from 'path' dotenv.config({ path: path.resolve(__dirname, '.env') }) +const testBaseURL = process.env.TEST_BASE_URL || 'https://testnet.test' +const ignoreHTTPSErrors = + process.env.PLAYWRIGHT_IGNORE_HTTPS_ERRORS === 'true' || + testBaseURL.startsWith('https://') + const testDir = defineBddConfig({ paths: ['features/**/*.feature'], require: ['features/steps/**/*.ts'] @@ -22,7 +27,8 @@ export default defineConfig({ timeout: 15 * 1000 }, use: { - baseURL: process.env.TEST_BASE_URL || 'http://localhost:4003', + baseURL: testBaseURL, + ignoreHTTPSErrors, trace: 'on-first-retry', screenshot: 'only-on-failure' }, From 549f0468d0d8fc0111ac5a0d3e4bb2bdd1440474 Mon Sep 17 00:00:00 2001 From: Stephan Butler Date: Sun, 5 Apr 2026 20:46:11 +0200 Subject: [PATCH 3/3] fix: epanded test for double deposit checking --- .../deposit-transactions-regression.feature | 30 ++ .../deposit-transactions-regression.steps.ts | 264 ++++++++++++++++++ e2e/features/steps/fixtures.ts | 8 + local/mockgatehub.yaml | 5 +- 4 files changed, 306 insertions(+), 1 deletion(-) create mode 100644 e2e/features/deposit-transactions-regression.feature create mode 100644 e2e/features/steps/deposit-transactions-regression.steps.ts diff --git a/e2e/features/deposit-transactions-regression.feature b/e2e/features/deposit-transactions-regression.feature new file mode 100644 index 000000000..f738ba71e --- /dev/null +++ b/e2e/features/deposit-transactions-regression.feature @@ -0,0 +1,30 @@ +Feature: Deposit transaction regressions + As a wallet user + I want deposits to create one transaction and a correct balance delta + So that transaction history and balances stay consistent + + Scenario: Iframe deposit creates a single transaction row with matching balance delta + Given I am a verified and logged-in wallet user + When I open the EUR default account for deposit checks + And I record the current account balance + And I record the current transactions count + And I complete a deposit of 11.00 EUR via the GateHub iframe + And I open the transactions page for deposit checks + Then the transaction count should increase by exactly 1 + When I wait 10 seconds and refresh transactions + Then the transaction count should still increase by exactly 1 + And the latest transaction amount should match the deposit amount + And the account balance increase should match the deposit amount + + Scenario: Local dialog deposit creates a single transaction row with matching balance delta + Given I am a verified and logged-in wallet user + When I open the EUR default account for deposit checks + And I record the current account balance + And I record the current transactions count + And I complete a deposit of 11.00 EUR via the local dialog + And I open the transactions page for deposit checks + Then the transaction count should increase by exactly 1 + When I wait 10 seconds and refresh transactions + Then the transaction count should still increase by exactly 1 + And the latest transaction amount should match the deposit amount + And the account balance increase should match the deposit amount diff --git a/e2e/features/steps/deposit-transactions-regression.steps.ts b/e2e/features/steps/deposit-transactions-regression.steps.ts new file mode 100644 index 000000000..7d82d4dbf --- /dev/null +++ b/e2e/features/steps/deposit-transactions-regression.steps.ts @@ -0,0 +1,264 @@ +import { expect } from '@playwright/test' +import type { Page } from '@playwright/test' +import { Given, Then, When } from './fixtures' + +const EPSILON = 0.01 + +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 +} + +async function readAccountBalance(page: Page) { + const balanceSection = page + .getByRole('heading', { name: 'Balance' }) + .locator('xpath=following-sibling::div[1]') + + const balanceText = await balanceSection.innerText() + + return parseAmountFromText(balanceText) +} + +When('I open the EUR default account for deposit checks', async ({ + page, + flow +}) => { + await page.goto('/') + await expect(page.getByRole('heading', { name: 'Accounts' })).toBeVisible() + + const defaultAccount = page + .locator('a[href*="/account/"]') + .filter({ hasText: 'EUR Account' }) + .first() + + await expect(defaultAccount).toBeVisible() + await defaultAccount.click() + + await expect(page).toHaveURL(/\/account\/.+/) + await expect(page.getByRole('heading', { name: 'Balance' })).toBeVisible() + + const url = new URL(page.url()) + flow.accountPath = `${url.pathname}${url.search}` + + await flow.takeScreenshot('deposit-check-account-opened') +}) + +When('I record the current account balance', async ({ page, flow }) => { + flow.initialBalance = await readAccountBalance(page) + await flow.takeScreenshot('deposit-check-initial-balance') +}) + +When('I record the current transactions count', async ({ page, flow }) => { + await page.goto('/transactions') + await expect(page).toHaveURL(/\/transactions/) + + const rows = page.locator('#transactionsList tbody tr.cursor-pointer') + flow.initialTransactionRows = await rows.count() + + await flow.takeScreenshot('deposit-check-initial-transactions') + + if (!flow.accountPath) { + throw new Error('Missing account path in flow state') + } + + await page.goto(flow.accountPath) + await expect(page).toHaveURL(/\/account\/.+/) +}) + +When( + 'I complete a deposit of {float} EUR via the GateHub iframe', + async ({ page, flow }, amount: number) => { + flow.depositAmount = amount + + await page.goto('/deposit') + await expect(page).toHaveURL(/\/deposit/, { + message: + 'Expected /deposit page — ensure MockGatehub is running and GATEHUB_IFRAME_MANAGED_RAMP_URL is configured' + }) + await expect(page.locator('iframe')).toBeVisible({ + message: 'Expected deposit iframe on /deposit page' + }) + + await expect(page.getByRole('heading', { name: 'Deposit' })).toBeVisible() + await flow.takeScreenshot('deposit-iframe-page-opened') + + const frame = page.frameLocator('iframe') + await expect(frame.locator('#amount')).toBeVisible() + + await frame.locator('#amount').fill(amount.toFixed(2)) + await frame.locator('#currency').selectOption('EUR') + await flow.takeScreenshot('deposit-iframe-filled') + + await frame.getByTestId('complete-button').click() + await expect(frame.locator('#status')).toContainText('successfully', { + timeout: 30_000 + }) + await flow.takeScreenshot('deposit-iframe-submitted') + + // Give webhook + UI caches a chance to settle before measuring balance. + await page.waitForTimeout(4000) + + if (!flow.accountPath) { + throw new Error('Missing account path in flow state') + } + + await page.goto(flow.accountPath) + await expect(page).toHaveURL(/\/account\/.+/) + + flow.postDepositBalance = await readAccountBalance(page) + await flow.takeScreenshot('deposit-iframe-post-balance') + } +) + +When( + 'I complete a deposit of {float} EUR via the local dialog', + async ({ page, flow }, amount: number) => { + flow.depositAmount = amount + + if (!flow.accountPath) { + throw new Error('Missing account path in flow state') + } + + await page.goto(flow.accountPath) + await expect(page.locator('#fund')).toBeVisible() + await page.locator('#fund').click() + + await expect(page.getByText('Deposit to your egg basket')).toBeVisible() + + await page.getByLabel('Amount').fill(amount.toFixed(2)) + await flow.takeScreenshot('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('deposit-dialog-submitted') + + // Give webhook + UI caches a chance to settle before measuring balance. + await page.waitForTimeout(4000) + + await page.goto(flow.accountPath) + await expect(page).toHaveURL(/\/account\/.+/) + + flow.postDepositBalance = await readAccountBalance(page) + await flow.takeScreenshot('deposit-dialog-post-balance') + } +) + +When('I open the transactions page for deposit checks', async ({ page, flow }) => { + await page.goto('/transactions') + await expect(page).toHaveURL(/\/transactions/) + await expect(page.getByRole('heading', { name: 'Transactions' })).toBeVisible() + + if (flow.initialTransactionRows === undefined) { + throw new Error('Missing initial transactions count in flow state') + } + + const expectedMinimumRows = flow.initialTransactionRows + 1 + let currentRows = 0 + + for (let attempt = 0; attempt < 6; attempt++) { + const rows = page.locator('#transactionsList tbody tr.cursor-pointer') + currentRows = await rows.count() + + if (currentRows >= expectedMinimumRows) { + break + } + + await page.waitForTimeout(2000) + await page.reload() + await expect(page).toHaveURL(/\/transactions/) + } + + flow.postDepositTransactionRows = currentRows + + expect(flow.postDepositTransactionRows).toBeGreaterThanOrEqual( + expectedMinimumRows + ) + + const rows = page.locator('#transactionsList tbody tr.cursor-pointer') + + await expect(rows.first()).toBeVisible() + + const amountCellText = await rows + .first() + .locator('td') + .nth(2) + .innerText() + + flow.latestTransactionAmount = Math.abs(parseAmountFromText(amountCellText)) + + await flow.takeScreenshot('deposit-check-post-transactions') +}) + +Then('the transaction count should increase by exactly 1', async ({ flow }) => { + expect(flow.initialTransactionRows).toBeDefined() + expect(flow.postDepositTransactionRows).toBeDefined() + + expect(flow.postDepositTransactionRows! - flow.initialTransactionRows!).toBe(1) +}) + +When('I wait {int} seconds and refresh transactions', async ({ page, flow }, seconds: number) => { + await page.waitForTimeout(seconds * 1000) + 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') + flow.delayedRefreshTransactionRows = await rows.count() + + if (flow.delayedRefreshTransactionRows > 0) { + await expect(rows.first()).toBeVisible() + } + await flow.takeScreenshot('deposit-check-post-transactions-delayed-refresh') +}) + +Then( + 'the transaction count should still increase by exactly 1', + async ({ flow }) => { + expect(flow.initialTransactionRows).toBeDefined() + expect(flow.delayedRefreshTransactionRows).toBeDefined() + + expect( + flow.delayedRefreshTransactionRows! - flow.initialTransactionRows! + ).toBe(1) + } +) + +Then( + 'the latest transaction amount should match the deposit amount', + async ({ flow }) => { + expect(flow.depositAmount).toBeDefined() + expect(flow.latestTransactionAmount).toBeDefined() + + const delta = Math.abs(flow.latestTransactionAmount! - flow.depositAmount!) + expect(delta).toBeLessThanOrEqual(EPSILON) + } +) + +Then( + 'the account balance increase should match the deposit amount', + async ({ flow }) => { + expect(flow.initialBalance).toBeDefined() + expect(flow.postDepositBalance).toBeDefined() + expect(flow.depositAmount).toBeDefined() + + const increase = flow.postDepositBalance! - flow.initialBalance! + expect(Math.abs(increase - flow.depositAmount!)).toBeLessThanOrEqual(EPSILON) + } +) diff --git a/e2e/features/steps/fixtures.ts b/e2e/features/steps/fixtures.ts index d40e6bee8..62f178c4d 100644 --- a/e2e/features/steps/fixtures.ts +++ b/e2e/features/steps/fixtures.ts @@ -11,6 +11,14 @@ type FlowState = { containerName: string screenshotCounter: number verificationLink?: string + accountPath?: string + initialBalance?: number + postDepositBalance?: number + depositAmount?: number + initialTransactionRows?: number + postDepositTransactionRows?: number + delayedRefreshTransactionRows?: number + latestTransactionAmount?: number featureName: string takeScreenshot: (name: string) => Promise } diff --git a/local/mockgatehub.yaml b/local/mockgatehub.yaml index a178b3b5d..3875c978d 100644 --- a/local/mockgatehub.yaml +++ b/local/mockgatehub.yaml @@ -2,7 +2,10 @@ services: # MockGatehub - Mock Gatehub API service for local development mockgatehub: container_name: mockgatehub-local - image: ghcr.io/interledger/mockgatehub:1.12.3 + image: ghcr.io/interledger/mockgatehub:1.12.4 + # build: + # context: ../../mockgatehub + # dockerfile: Dockerfile ports: - '8080:8080' environment: