diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index d7e7d9f..fd4c2b3 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -46,6 +46,41 @@ jobs: - name: Check Formatting run: npx prettier --check . + verify-e2e-tests: + name: Run E2E Tests (Playwright) + runs-on: ubuntu-latest + + steps: + - name: Checkout Code + uses: actions/checkout@v4 + + - name: Setup Node.js + uses: actions/setup-node@v4 + with: + node-version: 20 + + - name: Install pnpm + uses: pnpm/action-setup@v4 + with: + version: 10 + + - name: Install Dependencies + run: pnpm install --frozen-lockfile + + - name: Install Playwright Browsers + run: pnpm --filter web exec playwright install --with-deps chromium + + - name: Build Packages (SDK & Web App) + run: pnpm build + env: + NEXT_PUBLIC_REGISTRY_CONTRACT_ID: CABGWVIZFF62FG67ZGFEP67NEEY4WYTMFURDMFTKKNRDAFPKPOJDTN4C + NEXT_PUBLIC_INVOICE_CONTRACT_ID: CA4O3MR7LWHRSUDBNU6FY6UDFFYBN7TGBZXBDZB4OYYXFYXIFJ6RJF6B + NEXT_PUBLIC_POOL_CONTRACT_ID: CAKEWH7SJCXGV2MH2WZYIX3QDPTSSBQFXYVYBOWAGLNBBZMPLE2US6CS + NEXT_PUBLIC_ESCROW_CONTRACT_ID: CAJWGUKDTTC3SKN4RAAY72J4DVIIYSCFHX6GIMNTT22ABMISJK4GBCEH + + - name: Run E2E Tests + run: pnpm test:e2e + verify-go-indexer: name: Verify Go Indexer & API runs-on: ubuntu-latest @@ -57,7 +92,7 @@ jobs: - name: Setup Go uses: actions/setup-go@v5 with: - go-version: "1.22" + go-version-file: "indexer/go.mod" - name: Get Dependencies run: | diff --git a/.gitignore b/.gitignore index 07ea18a..38e3ff6 100644 --- a/.gitignore +++ b/.gitignore @@ -41,3 +41,10 @@ pnpm-debug.log* .DS_Store Thumbs.db .neon +create_pr.ps1 +build_output.txt + +# Playwright +playwright-report/ +test-results/ +blob-report/ diff --git a/apps/web/e2e/fixtures/freighter.ts b/apps/web/e2e/fixtures/freighter.ts index a7cc254..9453e7d 100644 --- a/apps/web/e2e/fixtures/freighter.ts +++ b/apps/web/e2e/fixtures/freighter.ts @@ -8,7 +8,10 @@ export const test = base.extend({ isConnected: () => Promise.resolve(true), isAllowed: () => Promise.resolve(true), setAllowed: () => Promise.resolve(), - requestAccess: () => Promise.resolve(""), + requestAccess: () => + Promise.resolve( + "GBMOCKWALLETADDRESSXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXX", + ), signTransaction: (xdr: string) => Promise.resolve("signed-xdr-mock"), signAuthEntry: () => Promise.resolve("signed-auth-mock"), getPublicKey: () => diff --git a/apps/web/e2e/invoice-lifecycle.spec.ts b/apps/web/e2e/invoice-lifecycle.spec.ts index acabf1e..e5c6133 100644 --- a/apps/web/e2e/invoice-lifecycle.spec.ts +++ b/apps/web/e2e/invoice-lifecycle.spec.ts @@ -23,9 +23,9 @@ test.describe("Invoice Lifecycle - Happy Path", () => { await page.getByRole("button", { name: /Create Invoice/i }).click(); await page - .getByLabelText(/Buyer Address/i) + .getByLabel(/Buyer Address/i) .fill("GBBUYERXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXX"); - await page.getByLabelText(/Face Value/i).fill("1000"); + await page.getByLabel(/Face Value/i).fill("1000"); await page.getByRole("button", { name: /Next/i }).click(); await page.getByRole("button", { name: /Confirm & Create/i }).click(); diff --git a/apps/web/e2e/wallet-connect.spec.ts b/apps/web/e2e/wallet-connect.spec.ts new file mode 100644 index 0000000..ba99488 --- /dev/null +++ b/apps/web/e2e/wallet-connect.spec.ts @@ -0,0 +1,224 @@ +import { expect, test as baseTest } from "@playwright/test"; +import { test } from "./fixtures/freighter"; + +test.describe("Wallet Connect & SEP-10 Auth Flow", () => { + const MOCK_ADDRESS = "GBMOCKWALLETADDRESSXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXX"; + const MOCK_CHALLENGE_XDR = "AAAAAQAAAACmock_challenge_xdr_transaction"; + const MOCK_JWT_TOKEN = "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.mock_token"; + + test.beforeEach(async ({ page }) => { + // Intercept indexer SEP-10 authentication API calls + await page.route("**/auth?address=*", async (route) => { + await route.fulfill({ + status: 200, + contentType: "application/json", + body: JSON.stringify({ + transaction: MOCK_CHALLENGE_XDR, + network_passphrase: "Test SDF Network ; November 2015", + }), + }); + }); + + await page.route("**/auth", async (route) => { + if (route.request().method() === "POST") { + await route.fulfill({ + status: 200, + contentType: "application/json", + body: JSON.stringify({ + token: MOCK_JWT_TOKEN, + }), + }); + } else { + await route.continue(); + } + }); + + // Mock other indexer calls if needed + await page.route("**/pool/stats", async (route) => { + await route.fulfill({ + status: 200, + contentType: "application/json", + body: JSON.stringify({ + total_deposits: "10000000000", + total_funded: "5000000000", + available_liquidity: "5000000000", + utilization_rate_bps: 5000, + total_yield_distributed: "100000000", + active_invoice_count: 5, + total_shares: "10000000000", + }), + }); + }); + + await page.route("**/stats", async (route) => { + await route.fulfill({ + status: 200, + contentType: "application/json", + body: JSON.stringify({ + total_usdc_financed: "500000.00", + active_invoice_count: 5, + total_invoices: 12, + total_repaid: 7, + total_defaulted: 0, + average_yield_bps: 850, + pool_utilization_bps: 5000, + registered_issuers: 8, + }), + }); + }); + }); + + test("1. Connect wallet successfully with injected Freighter mock", async ({ + page, + }) => { + await page.goto("/"); + + // Verify initial disconnected state + const connectBtn = page.getByRole("button", { name: /CONNECT WALLET/i }); + await expect(connectBtn).toBeVisible(); + + // Click connect button + await connectBtn.click(); + + // Verify connected address (formatted address: GBMOCK...AAAA or GBMOCK...XXXX) + await expect(page.getByText(/GBMOCK\.\.\.XXXX/i)).toBeVisible(); + + // Verify network indicator + await expect(page.getByText("Testnet")).toBeVisible(); + }); + + test("2. Execute SEP-10 challenge-sign-verify authentication flow", async ({ + page, + }) => { + await page.goto("/"); + + // Connect wallet first + const connectBtn = page.getByRole("button", { name: /CONNECT WALLET/i }); + await connectBtn.click(); + await expect(page.getByText(/GBMOCK\.\.\.XXXX/i)).toBeVisible(); + + // Execute SEP-10 login flow programmatically via window evaluate or store + const authResult = await page.evaluate(async () => { + const walletStore = (window as any).__WALLET_STORE__ || {}; + const { fetchChallenge, verifyChallenge } = await import("@/lib/api"); + const { signTransaction } = await import("@stellar/freighter-api"); + + const address = "GBMOCKWALLETADDRESSXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXX"; + const challenge = await fetchChallenge(address); + const signedXdr = await signTransaction(challenge.transaction, { + network: "TESTNET", + networkPassphrase: challenge.network_passphrase, + accountToSign: address, + }); + const { token } = await verifyChallenge(signedXdr); + + return { + challengeTx: challenge.transaction, + signedXdr, + token, + }; + }); + + expect(authResult.challengeTx).toBe(MOCK_CHALLENGE_XDR); + expect(authResult.signedXdr).toBe("signed-xdr-mock"); + expect(authResult.token).toBe(MOCK_JWT_TOKEN); + }); + + test("3. Disconnect wallet and reset state", async ({ page }) => { + await page.goto("/"); + + // Connect wallet + const connectBtn = page.getByRole("button", { name: /CONNECT WALLET/i }); + await connectBtn.click(); + await expect(page.getByText(/GBMOCK\.\.\.XXXX/i)).toBeVisible(); + + // Disconnect wallet + const disconnectBtn = page.getByRole("button", { + name: /Disconnect wallet/i, + }); + await expect(disconnectBtn).toBeVisible(); + await disconnectBtn.click(); + + // Verify UI resets to disconnected state + await expect( + page.getByRole("button", { name: /CONNECT WALLET/i }), + ).toBeVisible(); + await expect(page.getByText(/GBMOCK\.\.\.XXXX/i)).not.toBeVisible(); + + // Verify Zustand storage in localStorage is cleared/reset + const storageState = await page.evaluate(() => { + const item = localStorage.getItem("wallet-storage"); + return item ? JSON.parse(item) : null; + }); + + expect(storageState?.state?.address).toBeNull(); + }); + + test("4. Reconnection and state persistence across reloads", async ({ + page, + }) => { + await page.goto("/"); + + // Connect wallet + await page.getByRole("button", { name: /CONNECT WALLET/i }).click(); + await expect(page.getByText(/GBMOCK\.\.\.XXXX/i)).toBeVisible(); + + // Reload page + await page.reload(); + + // Verify state persists after reload + await expect(page.getByText(/GBMOCK\.\.\.XXXX/i)).toBeVisible(); + + // Disconnect and reconnect + await page.getByRole("button", { name: /Disconnect wallet/i }).click(); + await expect( + page.getByRole("button", { name: /CONNECT WALLET/i }), + ).toBeVisible(); + + await page.getByRole("button", { name: /CONNECT WALLET/i }).click(); + await expect(page.getByText(/GBMOCK\.\.\.XXXX/i)).toBeVisible(); + }); +}); + +baseTest.describe("Wallet Error Handling & Uninstalled Extension", () => { + baseTest( + "Displays user cancellation message when request is rejected", + async ({ page }) => { + await page.addInitScript(() => { + window.freighter = { + isConnected: () => Promise.resolve(true), + isAllowed: () => Promise.resolve(true), + setAllowed: () => Promise.resolve(), + requestAccess: () => + Promise.reject(new Error("User rejected this request")), + signTransaction: () => Promise.resolve(""), + signAuthEntry: () => Promise.resolve(""), + getPublicKey: () => Promise.resolve(""), + getNetworkDetails: () => Promise.resolve({ network: "TESTNET" }), + }; + }); + + await page.goto("/"); + const connectBtn = page.getByRole("button", { name: /CONNECT WALLET/i }); + await connectBtn.click(); + + await expect( + page.getByText(/You cancelled the connection request/i), + ).toBeVisible(); + }, + ); + + baseTest( + "Displays Install Freighter link when extension is missing", + async ({ page }) => { + await page.addInitScript(() => { + delete (window as any).freighter; + }); + + await page.goto("/"); + await expect( + page.getByRole("link", { name: /Install Freighter/i }), + ).toBeVisible(); + }, + ); +}); diff --git a/apps/web/package.json b/apps/web/package.json index cf1e971..9429577 100644 --- a/apps/web/package.json +++ b/apps/web/package.json @@ -8,7 +8,9 @@ "start": "next start", "lint": "next lint", "test": "vitest run --passWithNoTests", - "test:coverage": "vitest run --coverage --passWithNoTests" + "test:coverage": "vitest run --coverage --passWithNoTests", + "test:e2e": "playwright test", + "test:e2e:ui": "playwright test --ui" }, "dependencies": { "@radix-ui/react-slot": "^1.2.5", @@ -34,6 +36,7 @@ "zustand": "^5.0.14" }, "devDependencies": { + "@playwright/test": "^1.49.1", "@testing-library/jest-dom": "^6.9.1", "@testing-library/react": "^16.3.2", "@types/node": "^20", diff --git a/apps/web/vercel.json b/apps/web/vercel.json new file mode 100644 index 0000000..a667db8 --- /dev/null +++ b/apps/web/vercel.json @@ -0,0 +1,4 @@ +{ + "$schema": "https://openapi.vercel.sh/vercel.json", + "framework": "nextjs" +} diff --git a/package.json b/package.json index bf1b762..ed8e9e0 100644 --- a/package.json +++ b/package.json @@ -8,6 +8,7 @@ "build": "pnpm --filter @trusttrove/sdk build && pnpm --filter web build", "lint": "pnpm --filter web lint", "test": "pnpm --filter @trusttrove/sdk test && pnpm --filter web test", + "test:e2e": "pnpm --filter web test:e2e", "typecheck": "pnpm -r exec tsc --noEmit" }, "devDependencies": { diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 6e0cc6e..37b32ce 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -34,10 +34,10 @@ importers: version: link:../../packages/sdk '@vercel/analytics': specifier: ^2.0.1 - version: 2.0.1(next@14.2.35(react-dom@18.3.1(react@18.3.1))(react@18.3.1))(react@18.3.1) + version: 2.0.1(next@14.2.35(@playwright/test@1.62.1)(react-dom@18.3.1(react@18.3.1))(react@18.3.1))(react@18.3.1) '@vercel/speed-insights': specifier: ^2.0.0 - version: 2.0.0(next@14.2.35(react-dom@18.3.1(react@18.3.1))(react@18.3.1))(react@18.3.1) + version: 2.0.0(next@14.2.35(@playwright/test@1.62.1)(react-dom@18.3.1(react@18.3.1))(react@18.3.1))(react@18.3.1) class-variance-authority: specifier: ^0.7.1 version: 0.7.1 @@ -52,7 +52,7 @@ importers: version: 1.23.0(react@18.3.1) next: specifier: 14.2.35 - version: 14.2.35(react-dom@18.3.1(react@18.3.1))(react@18.3.1) + version: 14.2.35(@playwright/test@1.62.1)(react-dom@18.3.1(react@18.3.1))(react@18.3.1) react: specifier: ^18 version: 18.3.1 @@ -78,6 +78,9 @@ importers: specifier: ^5.0.14 version: 5.0.14(@types/react@18.3.31)(immer@11.1.15)(react@18.3.1)(use-sync-external-store@1.6.0(react@18.3.1)) devDependencies: + '@playwright/test': + specifier: ^1.49.1 + version: 1.62.1 '@testing-library/jest-dom': specifier: ^6.9.1 version: 6.9.1 @@ -563,6 +566,11 @@ packages: resolution: {integrity: sha512-+1VkjdD0QBLPodGrJUeqarH8VAIvQODIbwh9XpP5Syisf7YoQgsJKPNFoqqLQlu+VQ/tVSshMR6loPMn8U+dPg==} engines: {node: '>=14'} + '@playwright/test@1.62.1': + resolution: {integrity: sha512-DTcUc8qii+cpHvtOwggMtBRMjKZHXYWdw8syRYu2vtzuq4Wxphqq4NfCs5Zt44L6mA8rfDfj+PHnxFc/FeK6mQ==} + engines: {node: '>=20'} + hasBin: true + '@radix-ui/react-compose-refs@1.1.3': resolution: {integrity: sha512-rYOP8OMnuuPMQF1uhPVlGNcCDlkokKqGFE3JcxFViIkAXP7EvFWUliJAstrapypaBLJNHbZL6jGhbVDGTwmVhA==} peerDependencies: @@ -1605,8 +1613,8 @@ packages: eslint-plugin-import-x: optional: true - eslint-module-utils@2.14.0: - resolution: {integrity: sha512-W2WCRZ9Dqntd+2u8jJcVMV2PKulc6RdLgUUoh/yQr3uB6lo/ZOeGx11sv60/8S4QFFKNslAlWhr9u0Ef7ZW6Ig==} + eslint-module-utils@2.13.0: + resolution: {integrity: sha512-bLohSkT6469rRs8czj0tLTD8vaeIS/whvPRJVjDr7IuoTT1k5DYDERlNycjDj/HkOlvQdYurmfZ/g3fG5bgeLQ==} engines: {node: '>=4'} peerDependencies: '@typescript-eslint/parser': '*' @@ -1791,6 +1799,11 @@ packages: fs.realpath@1.0.0: resolution: {integrity: sha512-OO0pH2lK6a0hZnAdau5ItzHPI6pUlvI7jMVnxUQRtw4owF2wk8lOSabtGDCTP4Ggrg2MbGnWO9X8K1t4+fGMDw==} + 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} @@ -2467,6 +2480,16 @@ packages: resolution: {integrity: sha512-TfySrs/5nm8fQJDcBDuUng3VOUKsd7S+zqvbOTiGXHfxX4wK31ard+hoNuvkicM/2YFzlpDgABOevKSsB4G/FA==} engines: {node: '>= 6'} + playwright-core@1.62.1: + resolution: {integrity: sha512-wPYSwEBJY9GHraISXqyqtx0na0LpO3XEX7jNDhntbex7tzUS7kLnZsOlFruFJB4Hi/rhDMjXGqHewDZ68nYZVw==} + engines: {node: '>=20'} + hasBin: true + + playwright@1.62.1: + resolution: {integrity: sha512-0M+L3LAD8/nm554LOla9Ayx0j0tmFZ0FBcoQ7F1VuVHpM/XpiC8RcDzBQB8W5+hA8L22THxELzeF+2WcUzvcLg==} + engines: {node: '>=20'} + hasBin: true + possible-typed-array-names@1.1.0: resolution: {integrity: sha512-/+5VFTchJDoVj3bhoqi6UeymcD00DAwb1nJwamzPvHEszJ4FpF6SNNbUbOS8yI56qHzdV8eK0qEfOSiodkTdxg==} engines: {node: '>= 0.4'} @@ -3482,6 +3505,10 @@ snapshots: '@pkgjs/parseargs@0.11.0': optional: true + '@playwright/test@1.62.1': + dependencies: + playwright: 1.62.1 + '@radix-ui/react-compose-refs@1.1.3(@types/react@18.3.31)(react@18.3.1)': dependencies: react: 18.3.1 @@ -3872,14 +3899,14 @@ snapshots: '@unrs/resolver-binding-win32-x64-msvc@1.12.2': optional: true - '@vercel/analytics@2.0.1(next@14.2.35(react-dom@18.3.1(react@18.3.1))(react@18.3.1))(react@18.3.1)': + '@vercel/analytics@2.0.1(next@14.2.35(@playwright/test@1.62.1)(react-dom@18.3.1(react@18.3.1))(react@18.3.1))(react@18.3.1)': optionalDependencies: - next: 14.2.35(react-dom@18.3.1(react@18.3.1))(react@18.3.1) + next: 14.2.35(@playwright/test@1.62.1)(react-dom@18.3.1(react@18.3.1))(react@18.3.1) react: 18.3.1 - '@vercel/speed-insights@2.0.0(next@14.2.35(react-dom@18.3.1(react@18.3.1))(react@18.3.1))(react@18.3.1)': + '@vercel/speed-insights@2.0.0(next@14.2.35(@playwright/test@1.62.1)(react-dom@18.3.1(react@18.3.1))(react@18.3.1))(react@18.3.1)': optionalDependencies: - next: 14.2.35(react-dom@18.3.1(react@18.3.1))(react@18.3.1) + next: 14.2.35(@playwright/test@1.62.1)(react-dom@18.3.1(react@18.3.1))(react@18.3.1) react: 18.3.1 '@vitejs/plugin-react@6.0.3(vite@8.1.3(@types/node@20.19.43)(esbuild@0.28.1)(jiti@1.21.7)(tsx@4.22.5))': @@ -4545,7 +4572,7 @@ snapshots: transitivePeerDependencies: - supports-color - eslint-module-utils@2.14.0(@typescript-eslint/parser@8.62.1(eslint@8.57.1)(typescript@5.9.3))(eslint-import-resolver-node@0.3.10)(eslint-import-resolver-typescript@3.10.1)(eslint@8.57.1): + eslint-module-utils@2.13.0(@typescript-eslint/parser@8.62.1(eslint@8.57.1)(typescript@5.9.3))(eslint-import-resolver-node@0.3.10)(eslint-import-resolver-typescript@3.10.1)(eslint@8.57.1): dependencies: debug: 3.2.7 optionalDependencies: @@ -4567,7 +4594,7 @@ snapshots: doctrine: 2.1.0 eslint: 8.57.1 eslint-import-resolver-node: 0.3.10 - eslint-module-utils: 2.14.0(@typescript-eslint/parser@8.62.1(eslint@8.57.1)(typescript@5.9.3))(eslint-import-resolver-node@0.3.10)(eslint-import-resolver-typescript@3.10.1)(eslint@8.57.1) + eslint-module-utils: 2.13.0(@typescript-eslint/parser@8.62.1(eslint@8.57.1)(typescript@5.9.3))(eslint-import-resolver-node@0.3.10)(eslint-import-resolver-typescript@3.10.1)(eslint@8.57.1) hasown: 2.0.4 is-core-module: 2.16.2 is-glob: 4.0.3 @@ -4787,6 +4814,9 @@ snapshots: fs.realpath@1.0.0: {} + fsevents@2.3.2: + optional: true + fsevents@2.3.3: optional: true @@ -5315,7 +5345,7 @@ snapshots: natural-compare@1.4.0: {} - next@14.2.35(react-dom@18.3.1(react@18.3.1))(react@18.3.1): + next@14.2.35(@playwright/test@1.62.1)(react-dom@18.3.1(react@18.3.1))(react@18.3.1): dependencies: '@next/env': 14.2.35 '@swc/helpers': 0.5.5 @@ -5336,6 +5366,7 @@ snapshots: '@next/swc-win32-arm64-msvc': 14.2.33 '@next/swc-win32-ia32-msvc': 14.2.33 '@next/swc-win32-x64-msvc': 14.2.33 + '@playwright/test': 1.62.1 transitivePeerDependencies: - '@babel/core' - babel-plugin-macros @@ -5455,6 +5486,14 @@ snapshots: pirates@4.0.7: {} + playwright-core@1.62.1: {} + + playwright@1.62.1: + dependencies: + playwright-core: 1.62.1 + optionalDependencies: + fsevents: 2.3.2 + possible-typed-array-names@1.1.0: {} postcss-import@15.1.0(postcss@8.5.16): diff --git a/vercel.json b/vercel.json new file mode 100644 index 0000000..60f520a --- /dev/null +++ b/vercel.json @@ -0,0 +1,6 @@ +{ + "$schema": "https://openapi.vercel.sh/vercel.json", + "buildCommand": "pnpm build", + "outputDirectory": "apps/web/.next", + "framework": "nextjs" +}