From ca74fdcfddf83549c6fdcb2b963edcbbf6d181a4 Mon Sep 17 00:00:00 2001 From: Luis Date: Thu, 26 Feb 2026 10:43:29 -0800 Subject: [PATCH 1/2] chore: exploratory e2e --- .github/workflows/playwright.yml | 13 +- e2e/fixtures/base.ts | 63 +++ e2e/fixtures/wallet.ts | 22 ++ e2e/helpers/api-mocks.ts | 275 +++++++++++++ e2e/helpers/mock-provider.ts | 155 ++++++++ e2e/helpers/rpc-mocks.ts | 127 ++++++ e2e/helpers/subgraph-mocks.ts | 351 +++++++++++++++++ e2e/helpers/test-data.ts | 32 ++ e2e/mocks/discover-dtf.json | 96 +++++ e2e/mocks/protocol-metrics.json | 16 + e2e/tests/discover.spec.ts | 74 ++++ e2e/tests/dtf-auctions.spec.ts | 74 ++++ e2e/tests/dtf-governance.spec.ts | 162 ++++++++ e2e/tests/dtf-issuance.spec.ts | 85 ++++ e2e/tests/dtf-overview.spec.ts | 177 +++++++++ e2e/tests/dtf-settings.spec.ts | 138 +++++++ e2e/tests/earn.spec.ts | 244 ++++++++++++ e2e/tests/edge-cases.spec.ts | 371 ++++++++++++++++++ e2e/tests/navigation.spec.ts | 39 ++ e2e/tests/responsive.spec.ts | 146 +++++++ e2e/tests/ui-render.spec.ts | 287 ++++++++++++++ e2e/tests/wallet.spec.ts | 26 ++ package-lock.json | 21 +- package.json | 5 +- playwright.config.ts | 47 ++- src/components/account/index.tsx | 3 +- src/hooks/useIndexDTFList.ts | 5 +- .../discover/components/discover-tabs.tsx | 1 + .../index/components/basket-hover-card.tsx | 2 +- .../index/components/dtf-filters.tsx | 1 + .../index/components/index-dtf-card.tsx | 2 +- .../index/components/index-dtf-table.tsx | 2 +- .../components/index/discover-index-dtf.tsx | 2 +- src/views/index-dtf/auctions/index.tsx | 2 +- .../index-dtf/components/navigation/index.tsx | 2 +- .../components/governance-proposal-list.tsx | 2 +- src/views/index-dtf/governance/index.tsx | 2 +- src/views/index-dtf/issuance/index.tsx | 2 +- src/views/index-dtf/settings/index.tsx | 2 +- 39 files changed, 3029 insertions(+), 47 deletions(-) create mode 100644 e2e/fixtures/base.ts create mode 100644 e2e/fixtures/wallet.ts create mode 100644 e2e/helpers/api-mocks.ts create mode 100644 e2e/helpers/mock-provider.ts create mode 100644 e2e/helpers/rpc-mocks.ts create mode 100644 e2e/helpers/subgraph-mocks.ts create mode 100644 e2e/helpers/test-data.ts create mode 100644 e2e/mocks/discover-dtf.json create mode 100644 e2e/mocks/protocol-metrics.json create mode 100644 e2e/tests/discover.spec.ts create mode 100644 e2e/tests/dtf-auctions.spec.ts create mode 100644 e2e/tests/dtf-governance.spec.ts create mode 100644 e2e/tests/dtf-issuance.spec.ts create mode 100644 e2e/tests/dtf-overview.spec.ts create mode 100644 e2e/tests/dtf-settings.spec.ts create mode 100644 e2e/tests/earn.spec.ts create mode 100644 e2e/tests/edge-cases.spec.ts create mode 100644 e2e/tests/navigation.spec.ts create mode 100644 e2e/tests/responsive.spec.ts create mode 100644 e2e/tests/ui-render.spec.ts create mode 100644 e2e/tests/wallet.spec.ts diff --git a/.github/workflows/playwright.yml b/.github/workflows/playwright.yml index 90b6b700d..fc61f94c6 100644 --- a/.github/workflows/playwright.yml +++ b/.github/workflows/playwright.yml @@ -9,17 +9,20 @@ jobs: timeout-minutes: 60 runs-on: ubuntu-latest steps: - - uses: actions/checkout@v3 - - uses: actions/setup-node@v3 + - uses: actions/checkout@v4 + - uses: actions/setup-node@v4 with: - node-version: 18 + node-version: 20 + cache: 'npm' - name: Install dependencies run: npm ci - name: Install Playwright Browsers - run: npx playwright install --with-deps + run: npx playwright install --with-deps chromium - name: Run Playwright tests run: npx playwright test - - uses: actions/upload-artifact@v3 + env: + VITE_WALLETCONNECT_ID: 'test-project' + - uses: actions/upload-artifact@v4 if: always() with: name: playwright-report diff --git a/e2e/fixtures/base.ts b/e2e/fixtures/base.ts new file mode 100644 index 000000000..c60b20c37 --- /dev/null +++ b/e2e/fixtures/base.ts @@ -0,0 +1,63 @@ +import { test as base } from '@playwright/test' +import { mockApiRoutes } from '../helpers/api-mocks' +import { mockSubgraphRoutes } from '../helpers/subgraph-mocks' +import { mockRpcRoutes } from '../helpers/rpc-mocks' + +/** + * Extended test fixture that auto-mocks all external network calls. + * Every test gets mocked API, subgraph, and RPC responses automatically. + * + * Also dismisses the splash onboarding dialog and blocks unmocked external requests. + */ +export const test = base.extend<{ autoMock: void }>({ + autoMock: [ + async ({ page }, use) => { + await mockApiRoutes(page) + await mockSubgraphRoutes(page) + await mockRpcRoutes(page) + + // Block external requests that could cause flaky timeouts or leak data + await page.route('**/yields.llama.fi/**', (route) => { + route.fulfill({ + status: 200, + contentType: 'application/json', + body: JSON.stringify({ status: 'success', data: [] }), + }) + }) + await page.route('**/yields.reserve.org/**', (route) => { + route.fulfill({ + status: 200, + contentType: 'application/json', + body: JSON.stringify({ status: 'success', data: [] }), + }) + }) + + // Merkl campaign API — prevents real network calls from overview page + await page.route('**/api.merkl.xyz/**', (route) => { + route.fulfill({ + status: 200, + contentType: 'application/json', + body: JSON.stringify([]), + }) + }) + + // Block Sentry error reporting during tests + await page.route('**/sentry.io/**', (route) => route.abort()) + + // Block image CDNs — prevents non-deterministic network calls + await page.route('**/token-icons.llamao.fi/**', (route) => route.abort()) + await page.route('**/storage.reserve.org/**', (route) => route.abort()) + + // Dismiss the splash onboarding dialog that shows on first visit. + // The Splash component checks localStorage('splashVisible'). + await page.addInitScript(() => { + localStorage.setItem('splashVisible', 'false') + }) + + await use() + }, + { auto: true }, + ], +}) + +export { expect } from '@playwright/test' diff --git a/e2e/fixtures/wallet.ts b/e2e/fixtures/wallet.ts new file mode 100644 index 000000000..e9c84cc69 --- /dev/null +++ b/e2e/fixtures/wallet.ts @@ -0,0 +1,22 @@ +import { test as base, expect } from './base' +import { installTestWallet } from '../helpers/mock-provider' +import { TEST_ADDRESS, CHAINS } from '../helpers/test-data' + +/** + * Extended fixture with wallet mock installed. + * Test Wallet appears in RainbowKit's connect modal via EIP-6963. + */ +export const test = base.extend<{ wallet: void }>({ + wallet: [ + async ({ page }, use) => { + await installTestWallet(page, { + address: TEST_ADDRESS, + chainId: CHAINS.base.id, + }) + await use() + }, + { auto: true }, + ], +}) + +export { expect } diff --git a/e2e/helpers/api-mocks.ts b/e2e/helpers/api-mocks.ts new file mode 100644 index 000000000..1e14c2932 --- /dev/null +++ b/e2e/helpers/api-mocks.ts @@ -0,0 +1,275 @@ +import type { Page } from '@playwright/test' +import { readFileSync } from 'fs' +import { join, dirname } from 'path' +import { fileURLToPath } from 'url' + +const __dirname = dirname(fileURLToPath(import.meta.url)) +const mocksDir = join(__dirname, '..', 'mocks') + +const discoverDtfData = JSON.parse( + readFileSync(join(mocksDir, 'discover-dtf.json'), 'utf-8') +) +const protocolMetricsData = JSON.parse( + readFileSync(join(mocksDir, 'protocol-metrics.json'), 'utf-8') +) + +/** + * Mock all api.reserve.org endpoints with page.route() + * + * Uses a single route handler to avoid glob pattern issues with query strings. + * Playwright glob `**` doesn't match `?` in URLs reliably. + */ +export async function mockApiRoutes(page: Page) { + await page.route('**/api.reserve.org/**', (route) => { + const url = route.request().url() + const pathname = new URL(url).pathname + + // Discover DTF list — only return data for Base (8453), empty for other chains. + if (pathname.includes('/discover/dtf')) { + const chainId = new URL(url).searchParams.get('chainId') + const data = chainId === '8453' ? discoverDtfData : [] + return route.fulfill({ + status: 200, + contentType: 'application/json', + body: JSON.stringify(data), + }) + } + + // Protocol metrics (TVL, revenue, etc) + if (pathname.includes('/protocol/metrics')) { + return route.fulfill({ + status: 200, + contentType: 'application/json', + body: JSON.stringify(protocolMetricsData), + }) + } + + // Folio manager / brand data + // Code checks `response.status !== 'ok'` and throws if missing + if (pathname.includes('/folio-manager')) { + return route.fulfill({ + status: 200, + contentType: 'application/json', + body: JSON.stringify({ + status: 'ok', + parsedData: { + dtf: { + icon: '', + cover: '', + mobileCover: '', + description: + 'A diversified large cap index tracking the top crypto assets by market capitalization.', + notesFromCreator: 'Rebalanced quarterly.', + prospectus: '', + tags: ['large-cap', 'index'], + basketType: 'percentage-based', + }, + creator: { + name: 'Reserve Protocol', + icon: '', + link: 'https://reserve.org', + }, + curator: { name: '', icon: '', link: '' }, + socials: { + twitter: 'https://twitter.com/reserveprotocol', + telegram: '', + discord: '', + website: 'https://reserve.org', + }, + }, + }), + }) + } + + // DTF exposure data — must be an array (ExposureGroup[]) + // The updater sets atom directly from response.json() + if (pathname.includes('/dtf/exposure')) { + return route.fulfill({ + status: 200, + contentType: 'application/json', + body: JSON.stringify([]), + }) + } + + // DTF icons + if (pathname.includes('/dtf/icons')) { + return route.fulfill({ + status: 200, + contentType: 'application/json', + body: JSON.stringify({}), + }) + } + + // Current DTF price + basket (useIndexPrice.ts) + // Must return { price, basket: [...] } or priceResult.basket.reduce() crashes + if (pathname.includes('/current/dtf')) { + return route.fulfill({ + status: 200, + contentType: 'application/json', + body: JSON.stringify({ + price: 1.25, + basket: [ + { + address: '0x4200000000000000000000000000000000000006', + amount: 0.15, + price: 2450.5, + weight: '20.00', + }, + { + address: '0x833589fcd6edb6e08f4c7c32d4f71b54bda02913', + amount: 0.35, + price: 1.0, + weight: '35.00', + }, + { + address: '0x2ae3f1ec7f1f5012cfeab0185bfc7aa3cf0dec22', + amount: 0.002, + price: 62450.25, + weight: '25.00', + }, + { + address: '0x0b3e328455c4059eeb9e3f84b5543f74e24e7e1b', + amount: 1.5, + price: 12.45, + weight: '20.00', + }, + ], + }), + }) + } + + // Historical DTF price data (use-dtf-price-history.ts) + if (pathname.includes('/historical/dtf')) { + return route.fulfill({ + status: 200, + contentType: 'application/json', + body: JSON.stringify({ timeseries: [] }), + }) + } + + // DTF price / quote data + if (pathname.includes('/dtf/price')) { + return route.fulfill({ + status: 200, + contentType: 'application/json', + body: JSON.stringify({ price: 1.0 }), + }) + } + + // Zapper widget healthcheck + quotes + if (pathname.includes('/zapper/')) { + return route.fulfill({ + status: 200, + contentType: 'application/json', + body: JSON.stringify({ status: 'ok', healthy: true }), + }) + } + + // Current token prices + if (pathname.includes('/current/prices')) { + return route.fulfill({ + status: 200, + contentType: 'application/json', + body: JSON.stringify({}), + }) + } + + // DTF DAOs / vote lock positions + if (pathname.includes('/dtf/daos')) { + return route.fulfill({ + status: 200, + contentType: 'application/json', + body: JSON.stringify([ + { + chainId: 8453, + token: { + address: '0x7e6d5c4b3a2f1e0d9c8b7a69f8e7d6c5b4a3f2e1', + name: 'Staked LCAP', + symbol: 'stLCAP', + decimals: 18, + price: 1.31, + }, + underlying: { + token: { + address: '0x4da9a0f397db1397902070f93a4d6ddbc0e0e6e8', + name: 'Large Cap Index DTF', + symbol: 'LCAP', + decimals: 18, + price: 1.25, + }, + }, + rewards: [ + { + token: { + address: '0x4da9a0f397db1397902070f93a4d6ddbc0e0e6e8', + name: 'Large Cap Index DTF', + symbol: 'LCAP', + decimals: 18, + price: 1.25, + }, + amount: 125.5, + amountUsd: 156.88, + }, + ], + dtfs: [ + { + address: '0x4da9a0f397db1397902070f93a4d6ddbc0e0e6e8', + name: 'Large Cap Index DTF', + symbol: 'LCAP', + decimals: 18, + price: 1.25, + }, + ], + lockedAmount: 2500000, + lockedAmountUsd: 3125000, + totalRewardAmountUsd: 156.88, + avgDailyRewardAmountUsd: 5.23, + apr: 8.42, + }, + { + chainId: 8453, + token: { + address: '0xaa1111111111111111111111111111111111111111', + name: 'Staked CLX', + symbol: 'stCLX', + decimals: 18, + price: 2.15, + }, + underlying: { + token: { + address: '0x44551ca46fa5592bb572e20043f7c3d54c85cad7', + name: 'Clanker Index', + symbol: 'CLX', + decimals: 18, + price: 2.1, + }, + }, + rewards: [], + dtfs: [ + { + address: '0x44551ca46fa5592bb572e20043f7c3d54c85cad7', + name: 'Clanker Index', + symbol: 'CLX', + decimals: 18, + price: 2.1, + }, + ], + lockedAmount: 1200000, + lockedAmountUsd: 2520000, + totalRewardAmountUsd: 0, + avgDailyRewardAmountUsd: 0, + apr: 5.67, + }, + ]), + }) + } + + // Catch-all for any other reserve API calls + console.log(`[api-mock] unhandled: ${url}`) + route.fulfill({ + status: 200, + contentType: 'application/json', + body: JSON.stringify({}), + }) + }) +} diff --git a/e2e/helpers/mock-provider.ts b/e2e/helpers/mock-provider.ts new file mode 100644 index 000000000..11e485ed3 --- /dev/null +++ b/e2e/helpers/mock-provider.ts @@ -0,0 +1,155 @@ +import type { Page } from '@playwright/test' + +interface MockWalletConfig { + address: string + chainId: number +} + +/** + * Custom EIP-6963 wallet mock for Playwright. + * + * Two parts: + * 1. exposeFunction — bridges browser RPC calls → Node.js + * 2. addInitScript — injects EIP-6963 provider before app JS loads + * + * RainbowKit's injectedWallet listens for eip6963:announceProvider events, + * so our mock wallet appears in the connect modal automatically. + */ +export async function installTestWallet( + page: Page, + config: MockWalletConfig = { + address: '0xf39Fd6e51aad88F6F4ce6aB8827279cffFb92266', + chainId: 8453, + } +) { + let currentChainId = config.chainId + + // Bridge: browser calls this exposed function for all RPC requests + await page.exposeFunction( + 'eip1193Request', + async (request: { method: string; params?: unknown[] }) => { + switch (request.method) { + case 'eth_accounts': + case 'eth_requestAccounts': + return [config.address] + + case 'eth_chainId': + return '0x' + currentChainId.toString(16) + + case 'net_version': + return currentChainId.toString() + + case 'wallet_switchEthereumChain': { + const params = request.params as [{ chainId: string }] + if (params?.[0]?.chainId) { + currentChainId = parseInt(params[0].chainId, 16) + } + return null + } + + case 'wallet_requestPermissions': + return [{ parentCapability: 'eth_accounts' }] + + case 'wallet_getPermissions': + return [{ parentCapability: 'eth_accounts' }] + + case 'eth_sendTransaction': + // Return mock tx hash — Phase 2 will forward to Anvil + return '0x' + 'a'.repeat(64) + + case 'personal_sign': + return '0x' + 'b'.repeat(130) + + case 'eth_signTypedData_v4': + return '0x' + 'c'.repeat(130) + + case 'eth_blockNumber': + return '0x1000000' + + case 'eth_getBalance': + // 100 ETH + return '0x56BC75E2D63100000' + + case 'eth_estimateGas': + return '0x5208' + + case 'eth_gasPrice': + return '0x3B9ACA00' + + case 'eth_getCode': + return '0x' + + case 'eth_call': + return '0x' + + default: + console.log(`[test-wallet] unhandled method: ${request.method}`) + return null + } + } + ) + + // Inject EIP-6963 compliant provider before any app JS runs + await page.addInitScript( + ({ address, chainId }) => { + // Minimal EIP-1193 provider + const listeners: Record void>> = {} + + const provider = { + isTestWallet: true, + + request: async (req: { method: string; params?: unknown[] }) => { + return (window as any).eip1193Request({ + method: req.method, + params: req.params, + }) + }, + + on: (event: string, fn: (...args: unknown[]) => void) => { + if (!listeners[event]) listeners[event] = [] + listeners[event].push(fn) + return provider + }, + + removeListener: (event: string, fn: (...args: unknown[]) => void) => { + if (listeners[event]) { + listeners[event] = listeners[event].filter((l) => l !== fn) + } + return provider + }, + + emit: (event: string, ...args: unknown[]) => { + if (listeners[event]) { + listeners[event].forEach((fn) => fn(...args)) + } + }, + } + + // EIP-6963 provider info + const detail = Object.freeze({ + info: { + uuid: crypto.randomUUID(), + name: 'Test Wallet', + icon: 'data:image/svg+xml,T', + rdns: 'com.test.wallet', + }, + provider, + }) + + // Announce on eip6963:requestProvider (RainbowKit fires this on init) + const announce = () => { + window.dispatchEvent( + new CustomEvent('eip6963:announceProvider', { detail }) + ) + } + + // Announce immediately and on every request + announce() + window.addEventListener('eip6963:requestProvider', announce) + + // Also set as window.ethereum fallback + ;(window as any).ethereum = provider + }, + { address: config.address, chainId: config.chainId } + ) +} diff --git a/e2e/helpers/rpc-mocks.ts b/e2e/helpers/rpc-mocks.ts new file mode 100644 index 000000000..d2c576dab --- /dev/null +++ b/e2e/helpers/rpc-mocks.ts @@ -0,0 +1,127 @@ +import type { Page } from '@playwright/test' + +// All RPC provider URL patterns used in src/state/chain/index.tsx +const RPC_PATTERNS = [ + '**/publicnode.com/**', + '**/tenderly.co/**', + '**/infura.io/**', + '**/alchemyapi.io/**', + '**/alchemy.com/**', + '**/ankr.com/**', + '**/binance.org/**', + '**/ninicoin.io/**', + '**/defibit.io/**', + '**/llamarpc.com/**', +] + +interface RpcRequest { + jsonrpc: string + id: number + method: string + params?: unknown[] +} + +function handleRpcMethod(method: string): unknown { + switch (method) { + case 'eth_chainId': + return '0x2105' // Base + + case 'eth_blockNumber': + return '0x1000000' + + case 'net_version': + return '8453' + + case 'eth_getBalance': + return '0x0' + + case 'eth_gasPrice': + return '0x3B9ACA00' + + case 'eth_estimateGas': + return '0x5208' + + case 'eth_getCode': + // Return non-empty bytecode so wagmi recognizes addresses as contracts + return '0x6080604052' + + case 'eth_getTransactionCount': + return '0x0' + + case 'eth_call': + // Return empty bytes — most multicall/contract reads will get empty data + return '0x' + + case 'eth_getBlockByNumber': + return { + number: '0x1000000', + hash: '0x' + '0'.repeat(64), + timestamp: '0x' + Math.floor(Date.now() / 1000).toString(16), + transactions: [], + } + + case 'eth_getLogs': + return [] + + case 'eth_getTransactionReceipt': + return null + + default: + return '0x' + } +} + +function buildRpcResponse(id: number, result: unknown) { + return { jsonrpc: '2.0', id, result } +} + +/** + * Mock all RPC provider endpoints. + * Handles both single requests and batched requests (wagmi multicall sends arrays). + */ +export async function mockRpcRoutes(page: Page) { + for (const pattern of RPC_PATTERNS) { + await page.route(pattern, async (route) => { + const request = route.request() + + if (request.method() !== 'POST') { + return route.fulfill({ + status: 200, + contentType: 'application/json', + body: JSON.stringify({ jsonrpc: '2.0', id: 1, result: '0x' }), + }) + } + + try { + const body = request.postDataJSON() + + // Batched request (wagmi multicall) + if (Array.isArray(body)) { + const responses = body.map((req: RpcRequest) => + buildRpcResponse(req.id, handleRpcMethod(req.method)) + ) + return route.fulfill({ + status: 200, + contentType: 'application/json', + body: JSON.stringify(responses), + }) + } + + // Single request + const result = handleRpcMethod(body.method) + return route.fulfill({ + status: 200, + contentType: 'application/json', + body: JSON.stringify(buildRpcResponse(body.id, result)), + }) + } catch { + // If we can't parse the body, return a generic response + return route.fulfill({ + status: 200, + contentType: 'application/json', + body: JSON.stringify({ jsonrpc: '2.0', id: 1, result: '0x' }), + }) + } + }) + } +} diff --git a/e2e/helpers/subgraph-mocks.ts b/e2e/helpers/subgraph-mocks.ts new file mode 100644 index 000000000..9fa53f5be --- /dev/null +++ b/e2e/helpers/subgraph-mocks.ts @@ -0,0 +1,351 @@ +import type { Page } from '@playwright/test' +import { TEST_DTFS } from './test-data' + +// Realistic DTF entity that makes pages render actual content +const MOCK_DTF = { + id: TEST_DTFS.lcap.address, + proxyAdmin: '0x2dc04aeae96e2f2b642b066e981e80fe57abb5b2', + timestamp: 1704067200, + deployer: '0x8e0507c16435caca6cb71a7fb0e0636fd3891df4', + ownerAddress: '0x03d03a026e71979be3b08d44b01eae4c5ff9da99', + mintingFee: '100000000000000000', + tvlFee: '50000000000000000', + annualizedTvlFee: '5000000000000000000', + mandate: 'Large cap diversified index', + auctionDelay: '0', + auctionLength: '259200', + auctionApprovers: ['0xd84e0c72dc2f8363b46d4adfc58bfd82e49222d9'], + auctionLaunchers: ['0xd84e0c72dc2f8363b46d4adfc58bfd82e49222d9'], + brandManagers: ['0x2dc04aeae96e2f2b642b066e981e80fe57abb5b2'], + totalRevenue: 1250000, + protocolRevenue: 625000, + governanceRevenue: 312500, + externalRevenue: 312500, + feeRecipients: + '0x1111111111111111111111111111111111111111:500000000000000000,0x2222222222222222222222222222222222222222:500000000000000000', + ownerGovernance: { + id: '0x5a3e4b2c1a9f8d7e6c5b4a3f2e1d0c9b8a7f6e5d', + votingDelay: 1, + votingPeriod: 50400, + proposalThreshold: 1000000000000000000, + quorumNumerator: 4, + quorumDenominator: 100, + timelock: { + id: '0x4a3f2e1d0c9b8a7f6e5d4c3b2a19f8e7d6c5b4a3', + guardians: ['0x03d03a026e71979be3b08d44b01eae4c5ff9da99'], + executionDelay: 172800, + }, + }, + legacyAdmins: [], + tradingGovernance: { + id: '0x6b4e3d2c1b0a9f8e7d6c5b4a3f2e1d0c9b8a7f6e', + votingDelay: 1, + votingPeriod: 50400, + proposalThreshold: 500000000000000000, + quorumNumerator: 3, + quorumDenominator: 100, + timelock: { + id: '0x5c4b3a2f1e0d9c8b7a69f8e7d6c5b4a3f2e1d0c9', + guardians: ['0xd84e0c72dc2f8363b46d4adfc58bfd82e49222d9'], + executionDelay: 86400, + }, + }, + legacyAuctionApprovers: [], + token: { + id: TEST_DTFS.lcap.address, + name: 'Large Cap Index DTF', + symbol: 'LCAP', + decimals: 18, + totalSupply: '5000000000000000000000000', + currentHolderCount: 2847, + }, + stToken: { + id: '0x7e6d5c4b3a2f1e0d9c8b7a69f8e7d6c5b4a3f2e1', + token: { + name: 'Staked LCAP', + symbol: 'stLCAP', + decimals: 18, + totalSupply: '2500000000000000000000000', + }, + underlying: { + name: 'Large Cap Index DTF', + symbol: 'LCAP', + address: TEST_DTFS.lcap.address, + decimals: 18, + }, + governance: { + id: '0x8f7e6d5c4b3a2f1e0d9c8b7a69f8e7d6c5b4a3f2', + votingDelay: 1, + votingPeriod: 50400, + proposalThreshold: 1000000000000000000, + quorumNumerator: 4, + quorumDenominator: 100, + timelock: { + id: '0x9a8b7c6d5e4f3a2b1c0d9e8f7a6b5c4d3e2f1a0b', + guardians: ['0x03d03a026e71979be3b08d44b01eae4c5ff9da99'], + executionDelay: 172800, + }, + }, + legacyGovernance: [], + rewards: [ + { + rewardToken: { + address: '0xfbd70d29d26efc3d7d23a9f433f7079e8f6b08b9', + name: 'Ethereum', + symbol: 'ETH', + decimals: 18, + }, + }, + ], + }, +} + +// Governance proposals — mix of states so tests can verify real rendering +const MOCK_PROPOSALS = [ + { + id: '0x5a3e4b2c1a9f8d7e6c5b4a3f2e1d0c9b8a7f6e5d-1', + description: + 'Update basket allocation to increase ETH weighting from 15% to 20%', + creationTime: Math.floor(Date.now() / 1000) - 86400, // 1 day ago + creationBlock: 19214567, + state: 'Active', + forWeightedVotes: 2500000000000000000000, + abstainWeightedVotes: 300000000000000000000, + againstWeightedVotes: 400000000000000000000, + executionETA: Math.floor(Date.now() / 1000) + 172800, // 2 days from now + executionTime: null, + quorumVotes: 1000000000000000000000, + voteStart: Math.floor(Date.now() / 1000) - 86400, + voteEnd: Math.floor(Date.now() / 1000) + 259200, // 3 days from now + executionBlock: null, + proposer: { address: '0x03d03a026e71979be3b08d44b01eae4c5ff9da99' }, + }, + { + id: '0x5a3e4b2c1a9f8d7e6c5b4a3f2e1d0c9b8a7f6e5d-2', + description: 'Reduce minting fee from 0.1% to 0.08%', + creationTime: Math.floor(Date.now() / 1000) - 604800, // 7 days ago + creationBlock: 19197483, + state: 'Executed', + forWeightedVotes: 3500000000000000000000, + abstainWeightedVotes: 200000000000000000000, + againstWeightedVotes: 100000000000000000000, + executionETA: Math.floor(Date.now() / 1000) - 432000, + executionTime: String(Math.floor(Date.now() / 1000) - 345600), + quorumVotes: 1000000000000000000000, + voteStart: Math.floor(Date.now() / 1000) - 604800, + voteEnd: Math.floor(Date.now() / 1000) - 518400, + executionBlock: '19208345', + proposer: { address: '0xd84e0c72dc2f8363b46d4adfc58bfd82e49222d9' }, + }, + { + id: '0x5a3e4b2c1a9f8d7e6c5b4a3f2e1d0c9b8a7f6e5d-3', + description: 'Add new collateral type - Lido Staked Ether', + creationTime: Math.floor(Date.now() / 1000) - 1209600, // 14 days ago + creationBlock: 19180352, + state: 'Defeated', + forWeightedVotes: 1200000000000000000000, + abstainWeightedVotes: 400000000000000000000, + againstWeightedVotes: 2100000000000000000000, + executionETA: null, + executionTime: null, + quorumVotes: 1000000000000000000000, + voteStart: Math.floor(Date.now() / 1000) - 1209600, + voteEnd: Math.floor(Date.now() / 1000) - 1123200, + executionBlock: null, + proposer: { address: '0x2dc04aeae96e2f2b642b066e981e80fe57abb5b2' }, + }, +] + +// Transaction history +const MOCK_TRANSFER_EVENTS = [ + { + id: '0xabc-1', + hash: '0xabcd1234567890abcd1234567890abcd1234567890abcd1234567890abcd1234', + amount: '1500000000000000000000', + timestamp: String(Math.floor(Date.now() / 1000) - 3600), + to: { id: '0xaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa' }, + from: { id: '0x0000000000000000000000000000000000000000' }, + type: 'MINT', + }, + { + id: '0xabc-2', + hash: '0xabcd1234567890abcd1234567890abcd1234567890abcd1234567890abcd1235', + amount: '2500000000000000000000', + timestamp: String(Math.floor(Date.now() / 1000) - 7200), + to: { id: '0x0000000000000000000000000000000000000000' }, + from: { id: '0xbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb' }, + type: 'REDEEM', + }, + { + id: '0xabc-3', + hash: '0xabcd1234567890abcd1234567890abcd1234567890abcd1234567890abcd1236', + amount: '3000000000000000000000', + timestamp: String(Math.floor(Date.now() / 1000) - 14400), + to: { id: '0xcccccccccccccccccccccccccccccccccccccccc' }, + from: { id: '0x0000000000000000000000000000000000000000' }, + type: 'MINT', + }, +] + +// Staking token with delegates +const MOCK_STAKING_TOKEN = { + id: '0x7e6d5c4b3a2f1e0d9c8b7a69f8e7d6c5b4a3f2e1', + totalDelegates: 412, + token: { decimals: 18, totalSupply: '2500000000000000000000000' }, + delegates: [ + { + address: '0x03d03a026e71979be3b08d44b01eae4c5ff9da99', + delegatedVotes: 450000000000000000000, + numberVotes: 45, + }, + { + address: '0xd84e0c72dc2f8363b46d4adfc58bfd82e49222d9', + delegatedVotes: 380000000000000000000, + numberVotes: 38, + }, + { + address: '0x2dc04aeae96e2f2b642b066e981e80fe57abb5b2', + delegatedVotes: 325000000000000000000, + numberVotes: 32, + }, + ], +} + +/** + * Detect which query is being run from the POST body and return + * query-specific mock data. Falls back to empty response for unknown queries. + */ +function getResponseForQuery(body: string, url: string) { + const isIndexDtfSubgraph = url.includes('dtf-index') + + // Index DTF subgraph — return realistic data + if (isIndexDtfSubgraph) { + // getDTF query (useIndexDTF.ts) + if (body.includes('getDTF') || body.includes('dtf(id:')) { + return { data: { dtf: MOCK_DTF } } + } + + // transferEvents query (useIndexDTFTransactions.ts) + if (body.includes('transferEvents')) { + return { data: { transferEvents: MOCK_TRANSFER_EVENTS } } + } + + // getGovernanceStats query (governance/updater.tsx) + if (body.includes('getGovernanceStats') || body.includes('governances(')) { + return { + data: { + governances: [ + { + id: MOCK_DTF.ownerGovernance.id, + proposals: MOCK_PROPOSALS, + proposalCount: MOCK_PROPOSALS.length, + }, + ], + stakingToken: MOCK_STAKING_TOKEN, + }, + } + } + + // Rebalance queries + if (body.includes('rebalances') || body.includes('rebalance(')) { + return { + data: { + rebalances: [ + { + id: `${TEST_DTFS.lcap.address}-1`, + nonce: '1', + tokens: [ + { + id: '0x4200000000000000000000000000000000000006', + name: 'Wrapped Ether', + symbol: 'WETH', + decimals: 18, + }, + { + id: '0x833589fcd6edb6e08f4c7c32d4f71b54bda02913', + name: 'USD Coin', + symbol: 'USDC', + decimals: 6, + }, + ], + priceControl: '0', + weightLowLimit: ['1000000000000000000', '2000000000000000000'], + weightSpotLimit: ['2000000000000000000', '3500000000000000000'], + weightHighLimit: ['3000000000000000000', '5000000000000000000'], + rebalanceLowLimit: '900000000000000000', + rebalanceSpotLimit: '1000000000000000000', + rebalanceHighLimit: '1100000000000000000', + priceLowLimit: ['2400000000000000000000', '990000000000000000'], + priceHighLimit: ['2600000000000000000000', '1010000000000000000'], + restrictedUntil: '0', + availableUntil: String( + Math.floor(Date.now() / 1000) - 604800 + ), // 7 days ago (historical) + transactionHash: + '0xdef1234567890abcdef1234567890abcdef1234567890abcdef1234567890abc', + blockNumber: '19208345', // matches Executed proposal + timestamp: String(Math.floor(Date.now() / 1000) - 604800), + }, + ], + }, + } + } + } + + // Yield DTF subgraph or unknown — return safe empty data + return { + data: { + tokens: [], + tokenDailySnapshots: [], + proposals: [], + votes: [], + governances: [], + ownerGovernance: null, + tradingGovernance: null, + vaultGovernance: null, + stakingToken: null, + delegates: [], + trades: [], + rebalances: [], + auctions: [], + entries: [], + accounts: [], + stakeEvents: [], + unstakeEvents: [], + dtf: null, + rtoken: null, + governance: null, + token: null, + folio: null, + transferEvents: [], + }, + } +} + +/** + * Mock all Goldsky subgraph GraphQL endpoints. + * Index DTF queries return realistic data; yield/other queries return safe empty data. + */ +export async function mockSubgraphRoutes(page: Page) { + await page.route('**/api.goldsky.com/**', (route) => { + const request = route.request() + + if (request.method() === 'POST') { + const url = request.url() + const body = request.postData() || '' + const response = getResponseForQuery(body, url) + + route.fulfill({ + status: 200, + contentType: 'application/json', + body: JSON.stringify(response), + }) + } else { + route.fulfill({ + status: 200, + contentType: 'application/json', + body: JSON.stringify({}), + }) + } + }) +} diff --git a/e2e/helpers/test-data.ts b/e2e/helpers/test-data.ts new file mode 100644 index 000000000..1dd67448f --- /dev/null +++ b/e2e/helpers/test-data.ts @@ -0,0 +1,32 @@ +// Anvil default account #0 +export const TEST_ADDRESS = '0xf39Fd6e51aad88F6F4ce6aB8827279cffFb92266' + +export const CHAINS = { + base: { id: 8453, hex: '0x2105' }, + mainnet: { id: 1, hex: '0x1' }, +} as const + +// DTF addresses from mock data (Base chain) +export const TEST_DTFS = { + lcap: { + address: '0x4da9a0f397db1397902070f93a4d6ddbc0e0e6e8', + symbol: 'LCAP', + name: 'CF Large Cap Index', + chainId: 8453, + }, + clx: { + address: '0x44551ca46fa5592bb572e20043f7c3d54c85cad7', + symbol: 'CLX', + name: 'Clanker Index', + chainId: 8453, + }, + ai: { + address: '0xfe45eda533e97198d9f3deeda9ae6c147141f6f9', + symbol: 'AI', + name: 'AIndex', + chainId: 8453, + }, +} as const + +export const dtfOverviewUrl = (address: string, chain = 'base') => + `/${chain}/index-dtf/${address}/overview` diff --git a/e2e/mocks/discover-dtf.json b/e2e/mocks/discover-dtf.json new file mode 100644 index 000000000..1572ad102 --- /dev/null +++ b/e2e/mocks/discover-dtf.json @@ -0,0 +1,96 @@ +[ + { + "address": "0x4da9a0f397db1397902070f93a4d6ddbc0e0e6e8", + "name": "CF Large Cap Index", + "symbol": "LCAP", + "marketCap": 3539310.45, + "fee": 1.5, + "basket": [ + { "address": "0xcbB7C0000aB88B473b1f5aFd9ef808440eed33Bf", "symbol": "cbBTC", "name": "Coinbase Wrapped BTC", "weight": "44.40" }, + { "address": "0x4200000000000000000000000000000000000006", "symbol": "WETH", "name": "Wrapped Ether", "weight": "22.59" }, + { "address": "0x2615a94df961278DcbC41Fb0a54fEc5f10a693aE", "symbol": "uXRP", "name": "XRP (Universal)", "weight": "15.20" }, + { "address": "0x9B8Df6E244526ab5F6e6400d331DB28C8fdDdb55", "symbol": "uSOL", "name": "Solana (Universal)", "weight": "9.44" } + ], + "price": 6.20, + "performance": [ + { "value": 5.91, "timestamp": 1771451791 }, + { "value": 5.94, "timestamp": 1771538176 }, + { "value": 6.03, "timestamp": 1771624573 }, + { "value": 6.06, "timestamp": 1771710978 }, + { "value": 5.95, "timestamp": 1771797372 }, + { "value": 5.71, "timestamp": 1771883777 }, + { "value": 5.67, "timestamp": 1771970162 }, + { "value": 6.26, "timestamp": 1772056576 } + ], + "chainId": 8453, + "brand": { + "icon": "https://l5394zf57b.ufs.sh/f/mupND8QUUvXxBVjyB3T4H9uivWURqQkxfgFXD7tedNTwsYoS", + "cover": "https://l5394zf57b.ufs.sh/f/mupND8QUUvXxyoERsLrX3EJomHD1USwYFd2GKiBg5a8eIx7n", + "tags": ["Majors", "Bitcoin", "L1"], + "about": "" + }, + "mandate": "The CF Large Cap Index tracks the performance of large-cap digital assets." + }, + { + "address": "0x44551ca46fa5592bb572e20043f7c3d54c85cad7", + "name": "Clanker Index", + "symbol": "CLX", + "marketCap": 122888.72, + "fee": 2, + "basket": [ + { "address": "0x1bc0c42215582d5A085795f4baDbaC3ff36d1Bcb", "symbol": "CLANKER", "name": "tokenbot", "weight": "16.95" }, + { "address": "0x22aF33FE49fD1Fa80c7149773dDe5890D3c76F3b", "symbol": "BNKR", "name": "BankrCoin", "weight": "52.67" }, + { "address": "0xf30Bf00edd0C22db54C9274B90D2A4C21FC09b07", "symbol": "FELIX", "name": "FELIX", "weight": "1.13" } + ], + "price": 1.03, + "performance": [ + { "value": 0.97, "timestamp": 1771451791 }, + { "value": 1.03, "timestamp": 1771538176 }, + { "value": 1.06, "timestamp": 1771624573 }, + { "value": 1.05, "timestamp": 1771710978 }, + { "value": 1.04, "timestamp": 1771797372 }, + { "value": 0.97, "timestamp": 1771883777 }, + { "value": 0.94, "timestamp": 1771970162 }, + { "value": 1.04, "timestamp": 1772056576 } + ], + "chainId": 8453, + "brand": { + "icon": "https://l5394zf57b.ufs.sh/f/mupND8QUUvXxBA9RFeT4H9uivWURqQkxfgFXD7tedNTwsYoS", + "cover": "https://l5394zf57b.ufs.sh/f/mupND8QUUvXxYULL8okGhrj2ScsDNeUp3lRftPgCi0ZM65Vz", + "tags": ["Memes", "SocialFi"], + "about": "" + }, + "mandate": "One-click exposure to the best performing assets in the Clanker ecosystem." + }, + { + "address": "0xfe45eda533e97198d9f3deeda9ae6c147141f6f9", + "name": "AIndex", + "symbol": "AI", + "marketCap": 12952.44, + "fee": 2, + "basket": [ + { "address": "0x0b3e328455c4059EEb9e3f84b5543F74E24e7E1b", "symbol": "VIRTUAL", "name": "Virtual Protocol", "weight": "45.72" }, + { "address": "0xAC1Bd2486aAf3B5C0fc3Fd868558b082a531B2B4", "symbol": "TOSHI", "name": "Toshi", "weight": "7.03" }, + { "address": "0xacfE6019Ed1A7Dc6f7B508C02d1b04ec88cC21bf", "symbol": "VVV", "name": "Venice Token", "weight": "12.05" } + ], + "price": 0.044, + "performance": [ + { "value": 0.039, "timestamp": 1771451791 }, + { "value": 0.040, "timestamp": 1771538176 }, + { "value": 0.042, "timestamp": 1771624573 }, + { "value": 0.043, "timestamp": 1771710978 }, + { "value": 0.040, "timestamp": 1771797372 }, + { "value": 0.038, "timestamp": 1771883777 }, + { "value": 0.039, "timestamp": 1771970162 }, + { "value": 0.045, "timestamp": 1772056576 } + ], + "chainId": 8453, + "brand": { + "icon": "https://l5394zf57b.ufs.sh/f/mupND8QUUvXxYAFIGckGhrj2ScsDNeUp3lRftPgCi0ZM65Vz", + "cover": "https://l5394zf57b.ufs.sh/f/mupND8QUUvXxoS7Q0DpWJ8eBKlh20iDxdQzgc7AtSIO5MUyN", + "tags": ["AI", "Memes"], + "about": "AIndex provides diversified exposure to top AI agents and infrastructure projects." + }, + "mandate": "AIndex provides diversified exposure to AI agents and infrastructure projects on Base." + } +] diff --git a/e2e/mocks/protocol-metrics.json b/e2e/mocks/protocol-metrics.json new file mode 100644 index 000000000..a3c1d5800 --- /dev/null +++ b/e2e/mocks/protocol-metrics.json @@ -0,0 +1,16 @@ +{ + "status": "success", + "result": { + "marketCap": 105561513.33, + "tvl": 130196760.22, + "rsrLockedUSD": 7327425.69, + "rsrStakerAnnualizedRevenue": 453345.23, + "rTokenAnnualizedRevenue": 2073174.25, + "indexDTFAnnualizedRevenue": 185847.69, + "tvlTimeseries": [ + { "1": 120000000, "8453": 8000000, "42161": 2000000, "timestamp": 1771451791 }, + { "1": 121000000, "8453": 8100000, "42161": 2000000, "timestamp": 1771538176 }, + { "1": 122000000, "8453": 8200000, "42161": 2000000, "timestamp": 1771624573 } + ] + } +} diff --git a/e2e/tests/discover.spec.ts b/e2e/tests/discover.spec.ts new file mode 100644 index 000000000..a91c4cbd4 --- /dev/null +++ b/e2e/tests/discover.spec.ts @@ -0,0 +1,74 @@ +import { test, expect } from '../fixtures/base' +import { TEST_DTFS } from '../helpers/test-data' + +test.describe('Discover page', () => { + test.beforeEach(async ({ page }) => { + await page.goto('/') + }) + + test('renders TVL header with protocol metrics', async ({ page }) => { + const tvlHeading = page.getByText('TVL in Reserve') + await expect(tvlHeading).toBeVisible() + + // TVL value from mock: $130,196,760 + await expect(page.getByText('$130,196,760')).toBeVisible() + }) + + test('shows Index DTF list with data from API', async ({ page }) => { + // Default tab is "Index DTFs" — table should render with mocked data + const table = page.getByTestId('discover-dtf-table') + await expect(table).toBeVisible() + + // Check that DTF names from mock data appear (use first() to avoid + // strict mode — desktop table + mobile card both render) + await expect(page.getByText(TEST_DTFS.lcap.name).first()).toBeVisible() + await expect(page.getByText(TEST_DTFS.clx.name).first()).toBeVisible() + await expect(page.getByText(TEST_DTFS.ai.name).first()).toBeVisible() + }) + + test('can switch between DTF category tabs', async ({ page }) => { + const tabs = page.getByTestId('discover-tabs') + await expect(tabs).toBeVisible() + + // Click Yield DTFs tab + await tabs.getByRole('tab', { name: /Yield DTFs/ }).click() + + // Index DTF table should no longer be visible + await expect(page.getByTestId('discover-dtf-table')).not.toBeVisible() + + // Click back to Index DTFs + await tabs.getByRole('tab', { name: /Index DTFs/ }).click() + await expect(page.getByTestId('discover-dtf-table')).toBeVisible() + }) + + test('clicking a DTF row navigates to overview page', async ({ page }) => { + // Wait for table to load + const table = page.getByTestId('discover-dtf-table') + await expect(table).toBeVisible() + + // Click the first DTF link (use the link inside the desktop table) + await table.locator('a').filter({ hasText: TEST_DTFS.lcap.name }).first().click() + + // Should navigate to the DTF overview page + await expect(page).toHaveURL( + new RegExp(`/base/index-dtf/${TEST_DTFS.lcap.address}/overview`) + ) + }) + + test('search filter narrows down the DTF list', async ({ page }) => { + const table = page.getByTestId('discover-dtf-table') + await expect(table).toBeVisible() + + // All 3 DTFs should be visible initially + await expect(page.getByText(TEST_DTFS.lcap.name).first()).toBeVisible() + await expect(page.getByText(TEST_DTFS.ai.name).first()).toBeVisible() + + // Type in the search filter + const searchInput = page.getByPlaceholder('Search by name, ticker, tag or collateral') + await searchInput.fill('Large Cap') + + // Only LCAP should remain visible + await expect(page.getByText(TEST_DTFS.lcap.name).first()).toBeVisible() + await expect(page.getByText(TEST_DTFS.ai.name)).not.toBeVisible() + }) +}) diff --git a/e2e/tests/dtf-auctions.spec.ts b/e2e/tests/dtf-auctions.spec.ts new file mode 100644 index 000000000..e9d26e9a7 --- /dev/null +++ b/e2e/tests/dtf-auctions.spec.ts @@ -0,0 +1,74 @@ +import { test, expect } from '../fixtures/base' +import { TEST_DTFS } from '../helpers/test-data' + +const DTF_URL = `/base/index-dtf/${TEST_DTFS.lcap.address}/auctions` + +test.describe('DTF Auctions: Page Load', () => { + test.beforeEach(async ({ page }) => { + await page.goto(DTF_URL) + await expect(page.getByText('LCAP').first()).toBeVisible({ timeout: 10000 }) + }) + + test('renders without crash', async ({ page }) => { + await expect(page.getByText('unexpected error')).not.toBeVisible() + await expect(page.getByTestId('dtf-auctions')).toBeVisible() + }) + + test('shows both Active and Historical section headers', async ({ + page, + }) => { + await expect(page.getByText('Active Rebalances')).toBeVisible() + await expect(page.getByText('Historical Rebalances')).toBeVisible() + }) +}) + +test.describe('DTF Auctions: Active Rebalances', () => { + test.beforeEach(async ({ page }) => { + await page.goto(DTF_URL) + await expect(page.getByText('LCAP').first()).toBeVisible({ timeout: 10000 }) + }) + + test('shows empty state when no active rebalances', async ({ page }) => { + // Mock rebalance has availableUntil 7 days ago → all historical + await expect( + page.getByText('No rebalances found').first() + ).toBeVisible({ timeout: 10000 }) + }) +}) + +test.describe('DTF Auctions: Historical Rebalances', () => { + test.beforeEach(async ({ page }) => { + await page.goto(DTF_URL) + await expect(page.getByText('LCAP').first()).toBeVisible({ timeout: 10000 }) + }) + + test('historical section renders without crash', async ({ page }) => { + // The mock has 1 historical rebalance matched to the "Executed" proposal + // via executionBlock === blockNumber ('19208345') + // Section renders either rebalance items or "No rebalances found" + const historicalHeader = page.getByText('Historical Rebalances') + await expect(historicalHeader).toBeVisible({ timeout: 10000 }) + }) +}) + +test.describe('DTF Auctions: Navigation', () => { + test('navigating to auctions from overview works', async ({ page }) => { + await page.goto( + `/base/index-dtf/${TEST_DTFS.lcap.address}/overview` + ) + await expect(page.getByText('LCAP').first()).toBeVisible({ timeout: 10000 }) + + const nav = page.getByTestId('dtf-nav') + await nav.getByRole('link', { name: /Auctions/ }).click() + await expect(page).toHaveURL(/\/auctions/) + + await expect(page.getByTestId('dtf-auctions')).toBeVisible() + }) + + test('direct URL to auctions page loads', async ({ page }) => { + await page.goto(DTF_URL) + await expect(page.getByText('Active Rebalances')).toBeVisible({ + timeout: 10000, + }) + }) +}) diff --git a/e2e/tests/dtf-governance.spec.ts b/e2e/tests/dtf-governance.spec.ts new file mode 100644 index 000000000..e96027cd0 --- /dev/null +++ b/e2e/tests/dtf-governance.spec.ts @@ -0,0 +1,162 @@ +import { test, expect } from '../fixtures/base' +import { TEST_DTFS } from '../helpers/test-data' + +const DTF_URL = `/base/index-dtf/${TEST_DTFS.lcap.address}/governance` + +test.describe('DTF Governance: Page Load', () => { + test.beforeEach(async ({ page }) => { + await page.goto(DTF_URL) + await expect(page.getByText('LCAP').first()).toBeVisible({ timeout: 10000 }) + }) + + test('renders without crash and shows governance container', async ({ + page, + }) => { + await expect(page.getByText('unexpected error')).not.toBeVisible() + await expect(page.getByTestId('dtf-governance')).toBeVisible() + }) + + test('shows create proposal button', async ({ page }) => { + const proposals = page.getByTestId('governance-proposals') + await expect(proposals.getByText('Create proposal')).toBeVisible() + }) +}) + +test.describe('DTF Governance: Proposal List', () => { + test.beforeEach(async ({ page }) => { + await page.goto(DTF_URL) + await expect(page.getByText('LCAP').first()).toBeVisible({ timeout: 10000 }) + }) + + test('shows all 3 proposals from subgraph mock', async ({ page }) => { + const proposals = page.getByTestId('governance-proposals') + await expect(proposals).toBeVisible() + + await expect(proposals.getByText('Recent proposals')).toBeVisible() + + await expect( + proposals.getByText(/Update basket allocation/).first() + ).toBeVisible({ timeout: 10000 }) + await expect( + proposals.getByText(/Reduce minting fee/).first() + ).toBeVisible() + await expect( + proposals.getByText(/Add new collateral type/).first() + ).toBeVisible() + }) + + test('proposals are sorted newest first', async ({ page }) => { + const proposals = page.getByTestId('governance-proposals') + + // Wait for proposals to render + await expect( + proposals.getByText(/Update basket allocation/).first() + ).toBeVisible({ timeout: 10000 }) + + // Get only proposal links (not the "Create proposal" button in the header) + // Proposal links have href containing "proposal/" per ProposalListItem + const proposalLinks = proposals.locator('a[href*="proposal/"]') + const count = await proposalLinks.count() + expect(count).toBeGreaterThanOrEqual(3) + + // First proposal should be "Update basket" (1 day ago = newest) + await expect(proposalLinks.first()).toContainText( + 'Update basket allocation' + ) + }) + + test('shows correct state badges for each proposal', async ({ page }) => { + const proposals = page.getByTestId('governance-proposals') + + await expect( + proposals.getByText(/Update basket allocation/).first() + ).toBeVisible({ timeout: 10000 }) + + // 3 different states from mock data + await expect(proposals.getByText('Active').first()).toBeVisible() + await expect(proposals.getByText('Executed').first()).toBeVisible() + await expect(proposals.getByText('Defeated').first()).toBeVisible() + }) + + test('active proposal shows voting metrics', async ({ page }) => { + const proposals = page.getByTestId('governance-proposals') + + await expect( + proposals.getByText(/Update basket allocation/).first() + ).toBeVisible({ timeout: 10000 }) + + // Quorum indicator + await expect(proposals.getByText(/Quorum/).first()).toBeVisible() + + // Vote percentages (For/Against) + await expect(proposals.getByText(/Votes/).first()).toBeVisible() + }) + + test('clicking a proposal navigates to detail page', async ({ page }) => { + const proposals = page.getByTestId('governance-proposals') + + await expect( + proposals.getByText(/Update basket allocation/).first() + ).toBeVisible({ timeout: 10000 }) + + // Click the first proposal link + await proposals.getByText(/Update basket allocation/).first().click() + + // URL should contain /proposal/ + await expect(page).toHaveURL(/\/proposal\//) + }) +}) + +test.describe('DTF Governance: Stats Sidebar', () => { + test.beforeEach(async ({ page }) => { + await page.goto(DTF_URL) + await expect(page.getByText('LCAP').first()).toBeVisible({ timeout: 10000 }) + }) + + test('shows proposal count from mock data', async ({ page }) => { + const governance = page.getByTestId('dtf-governance') + + // governanceStatsAtom derives proposalCount from subgraph + await expect(governance.getByText('Proposals').first()).toBeVisible() + }) + + test('shows voting addresses stat', async ({ page }) => { + const governance = page.getByTestId('dtf-governance') + + await expect( + governance.getByText('Voting Addresses').first() + ).toBeVisible() + }) + + test('shows vote supply stat', async ({ page }) => { + const governance = page.getByTestId('dtf-governance') + + await expect( + governance.getByText('Vote Supply').first() + ).toBeVisible() + }) + + test('shows vote locked section with stToken symbol', async ({ page }) => { + const governance = page.getByTestId('dtf-governance') + + // VotingPower component shows "Vote locked" label + await expect( + governance.getByText('Vote locked').first() + ).toBeVisible() + }) +}) + +test.describe('DTF Governance: Navigation', () => { + test('navigating to governance from overview works', async ({ page }) => { + await page.goto( + `/base/index-dtf/${TEST_DTFS.lcap.address}/overview` + ) + await expect(page.getByText('LCAP').first()).toBeVisible({ timeout: 10000 }) + + const nav = page.getByTestId('dtf-nav') + await nav.getByRole('link', { name: /Governance/ }).click() + await expect(page).toHaveURL(/\/governance/) + + await expect(page.getByTestId('governance-proposals')).toBeVisible() + }) +}) diff --git a/e2e/tests/dtf-issuance.spec.ts b/e2e/tests/dtf-issuance.spec.ts new file mode 100644 index 000000000..2e7b6ebbc --- /dev/null +++ b/e2e/tests/dtf-issuance.spec.ts @@ -0,0 +1,85 @@ +import { test, expect } from '../fixtures/wallet' +import { TEST_DTFS } from '../helpers/test-data' + +const ISSUANCE_URL = `/base/index-dtf/${TEST_DTFS.lcap.address}/issuance` +const MANUAL_URL = `/base/index-dtf/${TEST_DTFS.lcap.address}/issuance/manual` + +test.describe('DTF Issuance: Page Load', () => { + test('renders without crash', async ({ page }) => { + await page.goto(ISSUANCE_URL) + await expect(page.getByText('unexpected error')).not.toBeVisible() + await expect(page.getByTestId('dtf-nav')).toBeVisible() + }) + + test('navigation to issuance from overview works', async ({ page }) => { + await page.goto( + `/base/index-dtf/${TEST_DTFS.lcap.address}/overview` + ) + await expect(page.getByText('LCAP').first()).toBeVisible({ timeout: 10000 }) + + const nav = page.getByTestId('dtf-nav') + await nav.getByRole('link', { name: /Mint/ }).click() + await expect(page).toHaveURL(/\/issuance/) + }) + + test('direct URL to issuance page loads', async ({ page }) => { + await page.goto(ISSUANCE_URL) + await expect(page.getByTestId('dtf-nav')).toBeVisible({ timeout: 10000 }) + }) +}) + +test.describe('DTF Manual Issuance: Mint Mode', () => { + test.beforeEach(async ({ page }) => { + await page.goto(MANUAL_URL) + await expect(page.getByText('LCAP').first()).toBeVisible({ timeout: 10000 }) + }) + + test('shows mint form with amount input', async ({ page }) => { + await expect(page.getByText('Mint Amount:').first()).toBeVisible() + await expect( + page.locator('input[placeholder="0"]').first() + ).toBeVisible() + }) + + test('shows required approvals section', async ({ page }) => { + await expect( + page.getByText('Required Approvals').first() + ).toBeVisible({ timeout: 10000 }) + }) + + test('shows link back to zap minting', async ({ page }) => { + await expect( + page.getByText(/Having issues minting/).first() + ).toBeVisible() + }) +}) + +test.describe('DTF Manual Issuance: Mode Switching', () => { + test.beforeEach(async ({ page }) => { + await page.goto(MANUAL_URL) + await expect(page.getByText('LCAP').first()).toBeVisible({ timeout: 10000 }) + }) + + test('defaults to mint (buy) mode', async ({ page }) => { + await expect(page.getByText('Mint Amount:').first()).toBeVisible() + }) + + test('can switch to redeem (sell) mode', async ({ page }) => { + await page.getByRole('radio', { name: /sell/i }).click() + + await expect(page.getByText('Redeem Amount:').first()).toBeVisible() + await expect( + page.getByText('You will receive').first() + ).toBeVisible() + }) + + test('can switch back to mint after redeem', async ({ page }) => { + // Switch to redeem + await page.getByRole('radio', { name: /sell/i }).click() + await expect(page.getByText('Redeem Amount:').first()).toBeVisible() + + // Switch back to mint + await page.getByRole('radio', { name: /buy/i }).click() + await expect(page.getByText('Mint Amount:').first()).toBeVisible() + }) +}) diff --git a/e2e/tests/dtf-overview.spec.ts b/e2e/tests/dtf-overview.spec.ts new file mode 100644 index 000000000..adc2f6943 --- /dev/null +++ b/e2e/tests/dtf-overview.spec.ts @@ -0,0 +1,177 @@ +import { test, expect } from '../fixtures/base' +import { TEST_DTFS } from '../helpers/test-data' + +const DTF_URL = `/base/index-dtf/${TEST_DTFS.lcap.address}/overview` + +test.describe('DTF Overview: Page Load', () => { + test.beforeEach(async ({ page }) => { + await page.goto(DTF_URL) + await expect(page.getByText('LCAP').first()).toBeVisible({ timeout: 10000 }) + }) + + test('renders without error and nav is visible', async ({ page }) => { + await expect(page.getByText('unexpected error')).not.toBeVisible() + await expect(page.getByTestId('dtf-nav')).toBeVisible() + }) + + test('shows DTF name from subgraph mock', async ({ page }) => { + await expect( + page.getByText('Large Cap Index DTF').first() + ).toBeVisible() + }) + + test('shows DTF price from API mock', async ({ page }) => { + await expect(page.getByText('$1.25').first()).toBeVisible() + }) + + test('shows creator brand from folio-manager mock', async ({ page }) => { + await expect( + page.getByText('Reserve Protocol').first() + ).toBeVisible({ timeout: 10000 }) + }) +}) + +test.describe('DTF Overview: Navigation Sidebar', () => { + test.beforeEach(async ({ page }) => { + await page.goto(DTF_URL) + await expect(page.getByText('LCAP').first()).toBeVisible({ timeout: 10000 }) + }) + + test('shows all nav tabs', async ({ page }) => { + const nav = page.getByTestId('dtf-nav') + await expect(nav.getByRole('link', { name: /Overview/ })).toBeVisible() + await expect(nav.getByRole('link', { name: /Mint/ })).toBeVisible() + await expect(nav.getByRole('link', { name: /Governance/ })).toBeVisible() + await expect(nav.getByRole('link', { name: /Auctions/ })).toBeVisible() + await expect(nav.getByRole('link', { name: /Details/ })).toBeVisible() + }) + + test('clicking tabs updates URL and clicking back works', async ({ + page, + }) => { + const nav = page.getByTestId('dtf-nav') + + await nav.getByRole('link', { name: /Governance/ }).click() + await expect(page).toHaveURL(/\/governance/) + + await nav.getByRole('link', { name: /Auctions/ }).click() + await expect(page).toHaveURL(/\/auctions/) + + await nav.getByRole('link', { name: /Details/ }).click() + await expect(page).toHaveURL(/\/settings/) + + await nav.getByRole('link', { name: /Overview/ }).click() + await expect(page).toHaveURL(/\/overview/) + }) + + test('Overview tab is highlighted on initial load', async ({ page }) => { + const nav = page.getByTestId('dtf-nav') + const overviewLink = nav.getByRole('link', { name: /Overview/ }) + // Active link should have a distinguishing style (aria-current or specific class) + await expect(overviewLink).toHaveAttribute('aria-current', 'page') + }) +}) + +test.describe('DTF Overview: About Section', () => { + test.beforeEach(async ({ page }) => { + await page.goto(DTF_URL) + await expect(page.getByText('LCAP').first()).toBeVisible({ timeout: 10000 }) + }) + + test('shows about heading and brand description', async ({ page }) => { + await expect(page.getByText('About this DTF')).toBeVisible({ + timeout: 10000, + }) + await expect( + page + .getByText( + /diversified large cap index tracking the top crypto assets/ + ) + .first() + ).toBeVisible() + }) +}) + +test.describe('DTF Overview: Basket Composition', () => { + test.beforeEach(async ({ page }) => { + await page.goto(DTF_URL) + await expect(page.getByText('LCAP').first()).toBeVisible({ timeout: 10000 }) + }) + + test('shows basket table with Exposure and Collateral tabs', async ({ + page, + }) => { + await expect(page.getByText('Exposure').first()).toBeVisible() + await expect(page.getByText('Collateral').first()).toBeVisible() + }) + + test('shows Weight column header', async ({ page }) => { + await expect(page.getByText('Weight').first()).toBeVisible() + }) + + test('basket table renders with sortable Weight column', async ({ + page, + }) => { + // Basket overview has sortable columns (Weight, Performance) + // Weight column header should be clickable + const weightButton = page.getByRole('button', { name: /Weight/ }).first() + await expect(weightButton).toBeVisible({ timeout: 10000 }) + }) +}) + +test.describe('DTF Overview: Governance Section', () => { + test.beforeEach(async ({ page }) => { + await page.goto(DTF_URL) + await expect(page.getByText('LCAP').first()).toBeVisible({ timeout: 10000 }) + }) + + test('shows governance section when stToken exists', async ({ page }) => { + // Mock DTF has stToken defined → governance section renders + await expect( + page.getByText('Basket Governance').first() + ).toBeVisible({ timeout: 10000 }) + }) +}) + +test.describe('DTF Overview: Transaction History', () => { + test.beforeEach(async ({ page }) => { + await page.goto(DTF_URL) + await expect(page.getByText('LCAP').first()).toBeVisible({ timeout: 10000 }) + }) + + test('shows Transactions heading', async ({ page }) => { + const txHeader = page.getByText('Transactions') + await txHeader.scrollIntoViewIfNeeded() + await expect(txHeader).toBeVisible({ timeout: 10000 }) + }) + + test('shows Mint and Redeem transaction types from mock', async ({ + page, + }) => { + const txHeader = page.getByText('Transactions') + await txHeader.scrollIntoViewIfNeeded() + + // 2 mints and 1 redeem from subgraph mock + await expect(page.getByText('Mint').first()).toBeVisible() + await expect(page.getByText('Redeem').first()).toBeVisible() + }) + + test('transaction table has column headers', async ({ page }) => { + const txHeader = page.getByText('Transactions') + await txHeader.scrollIntoViewIfNeeded() + + await expect(page.getByText('Type').first()).toBeVisible() + await expect(page.getByText('Amount').first()).toBeVisible() + }) +}) + +test.describe('DTF Overview: Buy/Sell Sidebar', () => { + test('shows Buy/Sell call-to-action with DTF symbol', async ({ page }) => { + await page.goto(DTF_URL) + await expect(page.getByText('LCAP').first()).toBeVisible({ timeout: 10000 }) + + await expect( + page.getByText(/Buy\/Sell \$LCAP onchain/).first() + ).toBeVisible({ timeout: 10000 }) + }) +}) diff --git a/e2e/tests/dtf-settings.spec.ts b/e2e/tests/dtf-settings.spec.ts new file mode 100644 index 000000000..4f5d3f0e1 --- /dev/null +++ b/e2e/tests/dtf-settings.spec.ts @@ -0,0 +1,138 @@ +import { test, expect } from '../fixtures/base' +import { TEST_DTFS } from '../helpers/test-data' + +const DTF_URL = `/base/index-dtf/${TEST_DTFS.lcap.address}/settings` + +test.describe('DTF Settings: Page Load', () => { + test.beforeEach(async ({ page }) => { + await page.goto(DTF_URL) + await expect(page.getByText('LCAP').first()).toBeVisible({ timeout: 10000 }) + }) + + test('renders without crash', async ({ page }) => { + await expect(page.getByText('unexpected error')).not.toBeVisible() + await expect(page.getByTestId('dtf-settings')).toBeVisible() + }) +}) + +test.describe('DTF Settings: Basics Card', () => { + test.beforeEach(async ({ page }) => { + await page.goto(DTF_URL) + await expect(page.getByText('LCAP').first()).toBeVisible({ timeout: 10000 }) + }) + + test('shows Basics heading', async ({ page }) => { + const settings = page.getByTestId('dtf-settings') + await expect(settings.getByText('Basics')).toBeVisible() + }) + + test('shows DTF name from subgraph', async ({ page }) => { + const settings = page.getByTestId('dtf-settings') + await expect( + settings.getByText('Large Cap Index DTF').first() + ).toBeVisible() + }) + + test('shows all basic info labels', async ({ page }) => { + const settings = page.getByTestId('dtf-settings') + + await expect(settings.getByText('Name').first()).toBeVisible() + await expect(settings.getByText('Ticker').first()).toBeVisible() + await expect(settings.getByText('Address').first()).toBeVisible() + await expect(settings.getByText('Mandate').first()).toBeVisible() + await expect(settings.getByText('Deployer').first()).toBeVisible() + await expect(settings.getByText('Version').first()).toBeVisible() + }) + + test('shows mandate text from subgraph', async ({ page }) => { + const settings = page.getByTestId('dtf-settings') + await expect( + settings.getByText('Large cap diversified index').first() + ).toBeVisible() + }) + + test('shows shortened address for DTF and deployer', async ({ page }) => { + const settings = page.getByTestId('dtf-settings') + + // shortenAddress produces format like "0x4da9...0e6e8" (first 6 + last 5 chars) + // DTF address: TEST_DTFS.lcap.address + // Just verify Address label exists with a shortened value nearby + const addressRow = settings.getByText('Address').first() + await expect(addressRow).toBeVisible() + + const deployerRow = settings.getByText('Deployer').first() + await expect(deployerRow).toBeVisible() + }) +}) + +test.describe('DTF Settings: Fees & Revenue', () => { + test.beforeEach(async ({ page }) => { + await page.goto(DTF_URL) + await expect(page.getByText('LCAP').first()).toBeVisible({ timeout: 10000 }) + }) + + test('shows fees section heading', async ({ page }) => { + const settings = page.getByTestId('dtf-settings') + await expect( + settings.getByText('Fees & Revenue Distribution').first() + ).toBeVisible() + }) + + test('shows fee type labels', async ({ page }) => { + const settings = page.getByTestId('dtf-settings') + + await expect( + settings.getByText('Annualized TVL Fee').first() + ).toBeVisible() + await expect( + settings.getByText('Minting Fee').first() + ).toBeVisible() + }) + + test('shows fee distribution recipients', async ({ page }) => { + const settings = page.getByTestId('dtf-settings') + + // Fee recipients from feeRecipientsAtom + await expect( + settings.getByText('Fixed Platform Share').first() + ).toBeVisible() + }) +}) + +test.describe('DTF Settings: Governance Token', () => { + test.beforeEach(async ({ page }) => { + await page.goto(DTF_URL) + await expect(page.getByText('LCAP').first()).toBeVisible({ timeout: 10000 }) + }) + + test('shows governance token info from subgraph', async ({ page }) => { + const settings = page.getByTestId('dtf-settings') + + // stToken from mock: symbol "stLCAP" + await expect( + settings.getByText('stLCAP').first() + ).toBeVisible({ timeout: 10000 }) + }) +}) + +test.describe('DTF Settings: Navigation', () => { + test('navigating to settings from overview works', async ({ page }) => { + await page.goto( + `/base/index-dtf/${TEST_DTFS.lcap.address}/overview` + ) + await expect(page.getByText('LCAP').first()).toBeVisible({ timeout: 10000 }) + + const nav = page.getByTestId('dtf-nav') + await nav.getByRole('link', { name: /Details/ }).click() + await expect(page).toHaveURL(/\/settings/) + + await expect(page.getByTestId('dtf-settings')).toBeVisible() + }) + + test('direct URL to settings page loads', async ({ page }) => { + await page.goto(DTF_URL) + await expect(page.getByTestId('dtf-settings')).toBeVisible({ + timeout: 10000, + }) + }) +}) diff --git a/e2e/tests/earn.spec.ts b/e2e/tests/earn.spec.ts new file mode 100644 index 000000000..d7dbb3b2f --- /dev/null +++ b/e2e/tests/earn.spec.ts @@ -0,0 +1,244 @@ +import { test, expect } from '../fixtures/base' + +test.describe('Earn: Navigation', () => { + test('earn page renders with navigation tabs', async ({ page }) => { + await page.goto('/earn/index-dtf') + + // All 3 nav tabs visible + await expect( + page.getByText('Index DTF Governance').first() + ).toBeVisible({ timeout: 10000 }) + await expect( + page.getByText('Yield DTF Staking').first() + ).toBeVisible() + await expect(page.getByText('DeFi Yield').first()).toBeVisible() + }) + + test('navigate between earn tabs updates content', async ({ page }) => { + await page.goto('/earn/index-dtf') + + // Verify Index DTF page renders + await expect( + page.getByText('Vote-Lock on Index DTFs').first() + ).toBeVisible({ timeout: 10000 }) + + // Click Yield DTF tab + await page.getByText('Yield DTF Staking').first().click() + await expect(page).toHaveURL(/\/earn\/yield-dtf/) + await expect( + page.getByText('Stake RSR on Yield DTFs').first() + ).toBeVisible({ timeout: 10000 }) + + // Click DeFi Yield tab + await page.getByText('DeFi Yield').first().click() + await expect(page).toHaveURL(/\/earn\/defi/) + await expect( + page.getByText('Provide liquidity across DeFi').first() + ).toBeVisible({ timeout: 10000 }) + + // Click back to Index DTF + await page.getByText('Index DTF Governance').first().click() + await expect(page).toHaveURL(/\/earn\/index-dtf/) + await expect( + page.getByText('Vote-Lock on Index DTFs').first() + ).toBeVisible() + }) + + test('direct URL to each earn sub-route loads', async ({ page }) => { + // Index DTF + await page.goto('/earn/index-dtf') + await expect( + page.getByText('Vote-Lock on Index DTFs').first() + ).toBeVisible({ timeout: 10000 }) + + // Yield DTF + await page.goto('/earn/yield-dtf') + await expect( + page.getByText('Stake RSR on Yield DTFs').first() + ).toBeVisible({ timeout: 10000 }) + + // DeFi + await page.goto('/earn/defi') + await expect( + page.getByText('Provide liquidity across DeFi').first() + ).toBeVisible({ timeout: 10000 }) + }) +}) + +test.describe('Earn: Index DTF Governance', () => { + test.beforeEach(async ({ page }) => { + await page.goto('/earn/index-dtf') + await expect( + page.getByText('Vote-Lock on Index DTFs').first() + ).toBeVisible({ timeout: 10000 }) + }) + + test('header shows benefits on desktop', async ({ page }) => { + const viewport = page.viewportSize()! + if (viewport.width >= 768) { + await expect( + page.getByText('No Slashing Risk').first() + ).toBeVisible() + await expect( + page.getByText('7-day unlock delays').first() + ).toBeVisible() + await expect( + page.getByText('Payouts in DTF').first() + ).toBeVisible() + } + }) + + test('vote lock table renders with position data', async ({ page }) => { + // Wait for data to load — mock has 2 positions (LCAP, CLX) + await expect(page.getByText('LCAP').first()).toBeVisible({ + timeout: 10000, + }) + await expect(page.getByText('CLX').first()).toBeVisible() + + // Table headers + await expect(page.getByText('Gov. Token').first()).toBeVisible() + await expect(page.getByText('Avg. 30d%').first()).toBeVisible() + }) + + test('vote lock positions show TVL and APR data', async ({ page }) => { + await expect(page.getByText('LCAP').first()).toBeVisible({ + timeout: 10000, + }) + + // LCAP TVL: $3,125,000 + await expect(page.getByText('$3,125,000').first()).toBeVisible() + + // LCAP APR: 8.42% + await expect(page.getByText('8.42%').first()).toBeVisible() + + // CLX APR: 5.67% + await expect(page.getByText('5.67%').first()).toBeVisible() + }) + + test('vote lock positions show governs column', async ({ page }) => { + await expect(page.getByText('LCAP').first()).toBeVisible({ + timeout: 10000, + }) + + // Governs column header + await expect(page.getByText('Governs').first()).toBeVisible() + }) + + test('FAQ accordion renders with vote lock questions', async ({ + page, + }) => { + const faqTitle = page.getByText( + 'Vote Lock Frequently Asked Questions' + ) + await faqTitle.scrollIntoViewIfNeeded() + await expect(faqTitle).toBeVisible() + + // First FAQ is open by default (defaultValue="item-1") + await expect( + page.getByText('What is vote-locking?').first() + ).toBeVisible() + await expect( + page + .getByText(/locking an ERC20 token for a set period/) + .first() + ).toBeVisible() + + // Click second FAQ + await page + .getByText('Do I need to vote on proposals to earn rewards?') + .first() + .click() + await expect( + page + .getByText(/You earn rewards as long as your ERC2O tokens/) + .first() + ).toBeVisible() + }) +}) + +test.describe('Earn: Yield DTF Staking', () => { + test.beforeEach(async ({ page }) => { + await page.goto('/earn/yield-dtf') + await expect( + page.getByText('Stake RSR on Yield DTFs').first() + ).toBeVisible({ timeout: 10000 }) + }) + + test('header shows staking benefits', async ({ page }) => { + await expect( + page.getByText('Slashing Risk').first() + ).toBeVisible() + await expect( + page.getByText('14-day unlock delays').first() + ).toBeVisible() + await expect( + page.getByText('Payouts in RSR').first() + ).toBeVisible() + }) + + test('FAQ accordion renders with staking questions', async ({ + page, + }) => { + const faqTitle = page.getByText( + 'Staking Frequently Asked Questions' + ) + await faqTitle.scrollIntoViewIfNeeded() + await expect(faqTitle).toBeVisible() + + // First FAQ open by default + await expect( + page.getByText('What is staking?').first() + ).toBeVisible() + await expect( + page + .getByText( + /depositing RSR into a Yield DTF to provide first-loss capital/ + ) + .first() + ).toBeVisible() + }) + + test('table shows loading skeleton or empty state', async ({ + page, + }) => { + // Subgraph returns empty tokens for yield DTFs → table shows loading/empty + // Verify the table container renders without crash + await expect(page.getByText('Gov. Token').first()).toBeVisible({ + timeout: 10000, + }) + }) +}) + +test.describe('Earn: DeFi Yield', () => { + test.beforeEach(async ({ page }) => { + await page.goto('/earn/defi') + await expect( + page.getByText('Provide liquidity across DeFi').first() + ).toBeVisible({ timeout: 10000 }) + }) + + test('heading and info tooltip render', async ({ page }) => { + await expect( + page + .getByText( + /DeFi yield opportunities for DTFs in Aerodrome, Convex/ + ) + .first() + ).toBeVisible() + + await expect( + page.getByText('How are APYs so high?').first() + ).toBeVisible() + }) + + test('page renders without crash when pools are empty', async ({ + page, + }) => { + // With no pool data, featured pools should show skeletons and + // main table should render + // Just verify no crash — page loads and content is visible + await expect( + page.getByText('Provide liquidity across DeFi').first() + ).toBeVisible() + }) +}) diff --git a/e2e/tests/edge-cases.spec.ts b/e2e/tests/edge-cases.spec.ts new file mode 100644 index 000000000..ce844a6cf --- /dev/null +++ b/e2e/tests/edge-cases.spec.ts @@ -0,0 +1,371 @@ +import { test, expect, Page } from '@playwright/test' +import { mockSubgraphRoutes } from '../helpers/subgraph-mocks' +import { mockRpcRoutes } from '../helpers/rpc-mocks' +import { mockApiRoutes } from '../helpers/api-mocks' +import { TEST_DTFS } from '../helpers/test-data' + +/** + * Setup mocks without the auto-fixture so we can customize per-test. + */ +async function setupBaseMocks(page: Page) { + await mockRpcRoutes(page) + await page.route('**/api.merkl.xyz/**', (route) => + route.fulfill({ + status: 200, + contentType: 'application/json', + body: JSON.stringify([]), + }) + ) + await page.route('**/sentry.io/**', (route) => route.abort()) + await page.route('**/token-icons.llamao.fi/**', (route) => route.abort()) + await page.route('**/storage.reserve.org/**', (route) => route.abort()) + await page.route('**/yields.llama.fi/**', (route) => + route.fulfill({ + status: 200, + contentType: 'application/json', + body: JSON.stringify({ status: 'success', data: [] }), + }) + ) + await page.route('**/yields.reserve.org/**', (route) => + route.fulfill({ + status: 200, + contentType: 'application/json', + body: JSON.stringify({ status: 'success', data: [] }), + }) + ) + await page.addInitScript(() => { + localStorage.setItem('splashVisible', 'false') + }) +} + +test.describe('Edge case: Empty discover list', () => { + test('non-Base chain returns empty DTF list', async ({ page }) => { + await setupBaseMocks(page) + await mockSubgraphRoutes(page) + + // Custom API mock that returns empty for ALL chains + await page.route('**/api.reserve.org/**', (route) => { + const url = route.request().url() + const pathname = new URL(url).pathname + + if (pathname.includes('/discover/dtf')) { + return route.fulfill({ + status: 200, + contentType: 'application/json', + body: JSON.stringify([]), + }) + } + if (pathname.includes('/protocol/metrics')) { + return route.fulfill({ + status: 200, + contentType: 'application/json', + body: JSON.stringify({ tvl: 0, revenue: 0 }), + }) + } + route.fulfill({ + status: 200, + contentType: 'application/json', + body: JSON.stringify({}), + }) + }) + + await page.goto('/') + await expect(page.getByTestId('discover-dtf-table')).toBeVisible({ + timeout: 10000, + }) + + // Table should render but with no data rows + // The DataTable renders an empty tbody + const rows = page.locator('table tbody tr') + await expect(rows).toHaveCount(0) + }) +}) + +test.describe('Edge case: Discover search', () => { + test('search with no matches hides all DTFs', async ({ page }) => { + await setupBaseMocks(page) + await mockApiRoutes(page) + await mockSubgraphRoutes(page) + + await page.goto('/') + await expect(page.getByTestId('discover-dtf-table')).toBeVisible({ + timeout: 10000, + }) + + // Verify DTFs are visible first + await expect(page.getByText('LCAP').first()).toBeVisible() + + // Type a nonsense search term + await page.getByTestId('discover-search').fill('xyznonexistent99999') + await page.waitForTimeout(500) + + // No DTFs from mock data should be visible + await expect(page.getByText('LCAP')).not.toBeVisible() + await expect(page.getByText('CLX')).not.toBeVisible() + }) + + test('search is case-insensitive', async ({ page }) => { + await setupBaseMocks(page) + await mockApiRoutes(page) + await mockSubgraphRoutes(page) + + await page.goto('/') + await expect(page.getByTestId('discover-dtf-table')).toBeVisible({ + timeout: 10000, + }) + + // Search for "lcap" lowercase — should find "LCAP" + await page.getByTestId('discover-search').fill('lcap') + + await page.waitForTimeout(500) + + // Should still find results + await expect(page.getByText('LCAP').first()).toBeVisible() + }) +}) + +test.describe('Edge case: Governance with no proposals', () => { + test('shows "No proposals found" message', async ({ page }) => { + await setupBaseMocks(page) + await mockApiRoutes(page) + + // Custom subgraph mock that returns empty proposals + await page.route('**/api.goldsky.com/**', (route) => { + const request = route.request() + if (request.method() === 'POST') { + const body = request.postData() || '' + const url = request.url() + const isIndexDtf = url.includes('dtf-index') + + if (isIndexDtf) { + if (body.includes('getDTF') || body.includes('dtf(id:')) { + // Return DTF with stToken so governance page renders + return route.fulfill({ + status: 200, + contentType: 'application/json', + body: JSON.stringify({ + data: { + dtf: { + id: TEST_DTFS.lcap.address, + proxyAdmin: '0x2dc04aeae96e2f2b642b066e981e80fe57abb5b2', + timestamp: 1704067200, + deployer: + '0x8e0507c16435caca6cb71a7fb0e0636fd3891df4', + ownerAddress: + '0x03d03a026e71979be3b08d44b01eae4c5ff9da99', + mintingFee: '100000000000000000', + tvlFee: '50000000000000000', + annualizedTvlFee: '5000000000000000000', + mandate: 'Large cap diversified index', + auctionDelay: '0', + auctionLength: '259200', + auctionApprovers: [], + auctionLaunchers: [], + brandManagers: [], + totalRevenue: 0, + protocolRevenue: 0, + governanceRevenue: 0, + externalRevenue: 0, + feeRecipients: '', + ownerGovernance: { + id: '0x5a3e4b2c1a9f8d7e6c5b4a3f2e1d0c9b8a7f6e5d', + votingDelay: 1, + votingPeriod: 50400, + proposalThreshold: 1000000000000000000, + quorumNumerator: 4, + quorumDenominator: 100, + timelock: { + id: '0x4a3f2e1d0c9b8a7f6e5d4c3b2a19f8e7d6c5b4a3', + guardians: [], + executionDelay: 172800, + }, + }, + legacyAdmins: [], + tradingGovernance: null, + legacyAuctionApprovers: [], + token: { + id: TEST_DTFS.lcap.address, + name: 'Large Cap Index DTF', + symbol: 'LCAP', + decimals: 18, + totalSupply: '5000000000000000000000000', + currentHolderCount: 2847, + }, + stToken: { + id: '0x7e6d5c4b3a2f1e0d9c8b7a69f8e7d6c5b4a3f2e1', + token: { + name: 'Staked LCAP', + symbol: 'stLCAP', + decimals: 18, + totalSupply: '2500000000000000000000000', + }, + underlying: { + name: 'Large Cap Index DTF', + symbol: 'LCAP', + address: TEST_DTFS.lcap.address, + decimals: 18, + }, + governance: { + id: '0x8f7e6d5c4b3a2f1e0d9c8b7a69f8e7d6c5b4a3f2', + votingDelay: 1, + votingPeriod: 50400, + proposalThreshold: 1000000000000000000, + quorumNumerator: 4, + quorumDenominator: 100, + timelock: { + id: '0x9a8b7c6d5e4f3a2b1c0d9e8f7a6b5c4d3e2f1a0b', + guardians: [], + executionDelay: 172800, + }, + }, + legacyGovernance: [], + rewards: [], + }, + }, + }, + }), + }) + } + + // Return empty proposals for governance + if ( + body.includes('getGovernanceStats') || + body.includes('governances(') + ) { + return route.fulfill({ + status: 200, + contentType: 'application/json', + body: JSON.stringify({ + data: { + governances: [ + { + id: '0x5a3e4b2c1a9f8d7e6c5b4a3f2e1d0c9b8a7f6e5d', + proposals: [], + proposalCount: 0, + }, + ], + stakingToken: { + id: '0x7e6d5c4b3a2f1e0d9c8b7a69f8e7d6c5b4a3f2e1', + totalDelegates: 0, + token: { + decimals: 18, + totalSupply: '0', + }, + delegates: [], + }, + }, + }), + }) + } + } + + // Default empty response + route.fulfill({ + status: 200, + contentType: 'application/json', + body: JSON.stringify({ + data: { + tokens: [], + transferEvents: [], + rebalances: [], + governances: [], + }, + }), + }) + } else { + route.fulfill({ + status: 200, + contentType: 'application/json', + body: JSON.stringify({}), + }) + } + }) + + await page.goto( + `/base/index-dtf/${TEST_DTFS.lcap.address}/governance` + ) + await expect(page.getByText('LCAP').first()).toBeVisible({ + timeout: 10000, + }) + + const proposals = page.getByTestId('governance-proposals') + await expect(proposals).toBeVisible({ timeout: 10000 }) + + // Empty state message + await expect( + proposals.getByText('No proposals found') + ).toBeVisible({ timeout: 10000 }) + }) +}) + +test.describe('Edge case: API failure graceful degradation', () => { + test('overview page survives API returning 500', async ({ page }) => { + await setupBaseMocks(page) + await mockSubgraphRoutes(page) + + // Mock API to return 500 for everything + await page.route('**/api.reserve.org/**', (route) => { + route.fulfill({ + status: 500, + contentType: 'application/json', + body: JSON.stringify({ error: 'Internal Server Error' }), + }) + }) + + await page.goto( + `/base/index-dtf/${TEST_DTFS.lcap.address}/overview` + ) + + // Page should not show an uncaught error boundary + await expect(page.getByText('LCAP').first()).toBeVisible({ + timeout: 10000, + }) + + // Nav should still work — subgraph data loaded + const nav = page.getByTestId('dtf-nav') + await expect(nav).toBeVisible() + + // DTF name from subgraph should still render + await expect( + page.getByText('Large Cap Index DTF').first() + ).toBeVisible() + }) + + // NOTE: discover page CRASHES when protocol/metrics returns 500 + // ("Cannot read properties of undefined (reading 'slice')") + // This is a real bug found by E2E testing — tracked separately. +}) + +test.describe('Edge case: Browser navigation', () => { + test('back/forward navigation preserves page state', async ({ page }) => { + await setupBaseMocks(page) + await mockApiRoutes(page) + await mockSubgraphRoutes(page) + + // Start at discover + await page.goto('/') + await expect(page.getByTestId('discover-dtf-table')).toBeVisible({ + timeout: 10000, + }) + + // Navigate to DTF overview + await page.goto( + `/base/index-dtf/${TEST_DTFS.lcap.address}/overview` + ) + await expect(page.getByText('LCAP').first()).toBeVisible({ + timeout: 10000, + }) + + // Go back to discover + await page.goBack() + await expect(page.getByTestId('discover-dtf-table')).toBeVisible({ + timeout: 10000, + }) + + // Go forward to DTF overview + await page.goForward() + await expect(page.getByText('LCAP').first()).toBeVisible({ + timeout: 10000, + }) + }) +}) diff --git a/e2e/tests/navigation.spec.ts b/e2e/tests/navigation.spec.ts new file mode 100644 index 000000000..362c1e8ed --- /dev/null +++ b/e2e/tests/navigation.spec.ts @@ -0,0 +1,39 @@ +import { test, expect } from '../fixtures/base' + +test.describe('App navigation', () => { + test.beforeEach(async ({ page }) => { + await page.goto('/') + }) + + test('header shows brand, nav links, and Connect button', async ({ + page, + }) => { + // Navigation links visible in desktop header + await expect(page.getByRole('link', { name: /Discover DTFs/ })).toBeVisible() + + // Connect button + const connectBtn = page.getByTestId('header-connect-btn') + await expect(connectBtn).toBeVisible() + await expect(connectBtn).toHaveText(/Connect/) + }) + + test('navigate to Earn page via header link', async ({ page }) => { + await page.getByRole('link', { name: /Participate/ }).click() + await expect(page).toHaveURL(/\/earn/) + }) + + test('navigate to Create DTF page via header link', async ({ page }) => { + await page.getByRole('link', { name: /Create New DTF/ }).click() + await expect(page).toHaveURL(/\/deploy.*index/) + }) + + test('direct URL to DTF overview loads without crash', async ({ page }) => { + await page.goto('/base/index-dtf/0x4da9a0f397db1397902070f93a4d6ddbc0e0e6e8/overview') + + // Should not show an error boundary + await expect(page.getByText('unexpected error')).not.toBeVisible() + + // Page should have loaded (navigation should be visible) + await expect(page.getByTestId('dtf-nav')).toBeVisible() + }) +}) diff --git a/e2e/tests/responsive.spec.ts b/e2e/tests/responsive.spec.ts new file mode 100644 index 000000000..efb3cf478 --- /dev/null +++ b/e2e/tests/responsive.spec.ts @@ -0,0 +1,146 @@ +import { test, expect } from '../fixtures/base' +import { TEST_DTFS } from '../helpers/test-data' + +const DTF_URL = `/base/index-dtf/${TEST_DTFS.lcap.address}/overview` + +test.describe('Responsive: Discover page', () => { + test('mobile shows DTF cards instead of table', async ({ page }) => { + await page.goto('/') + await expect(page.getByTestId('discover-dtf-table')).toBeVisible({ + timeout: 10000, + }) + + // On mobile (< lg), the table is hidden and cards are shown + const viewport = page.viewportSize()! + if (viewport.width < 1024) { + // Table should be hidden, cards visible + const table = page.locator('table') + await expect(table).not.toBeVisible() + } else { + // Desktop: table is visible + const table = page.locator('table') + await expect(table.first()).toBeVisible() + } + }) + + test('header navigation adapts to viewport', async ({ page }) => { + await page.goto('/') + + const viewport = page.viewportSize()! + + if (viewport.width < 768) { + // Nav text labels hidden on mobile (hidden md:block) + await expect(page.getByText('Discover DTFs')).not.toBeVisible() + } else { + // Desktop: nav text labels visible in header + await expect( + page.getByText('Discover DTFs').first() + ).toBeVisible() + } + }) +}) + +test.describe('Responsive: DTF detail page', () => { + test.beforeEach(async ({ page }) => { + await page.goto(DTF_URL) + // On mobile, "LCAP" in nav header is hidden (hidden lg:flex). + // Wait for the DTF full name in the hero instead — always visible. + await expect( + page.getByText('Large Cap Index DTF').first() + ).toBeVisible({ timeout: 10000 }) + }) + + test('mobile shows bottom navigation bar', async ({ page }) => { + const viewport = page.viewportSize()! + const nav = page.getByTestId('dtf-nav') + await expect(nav).toBeVisible() + + if (viewport.width < 1024) { + // Mobile: nav is fixed at bottom, full width, horizontal + const box = await nav.boundingBox() + expect(box).toBeTruthy() + // Bottom nav should span full width + expect(box!.width).toBeGreaterThan(viewport.width * 0.9) + // Should be near bottom of viewport + expect(box!.y + box!.height).toBeGreaterThan(viewport.height - 80) + } else { + // Desktop: nav is a sidebar on the left, narrow + const box = await nav.boundingBox() + expect(box).toBeTruthy() + expect(box!.width).toBeLessThan(300) + } + }) + + test('mobile hides Buy/Sell sidebar', async ({ page }) => { + const viewport = page.viewportSize()! + + if (viewport.width < 1280) { + // Buy/Sell sidebar hidden below xl + await expect( + page.getByText(/Buy\/Sell \$LCAP onchain/) + ).not.toBeVisible() + } else { + await expect( + page.getByText(/Buy\/Sell \$LCAP onchain/).first() + ).toBeVisible({ timeout: 10000 }) + } + }) + + test('mobile hides basket Market Cap column', async ({ page }) => { + const viewport = page.viewportSize()! + + if (viewport.width < 640) { + // Market Cap column hidden below sm + const marketCapHeaders = page.getByText('Market Cap') + // Should exist in DOM but be hidden + await expect(marketCapHeaders.first()).not.toBeVisible() + } else { + await expect(page.getByText('Market Cap').first()).toBeVisible() + } + }) + + test('DTF name visible in nav on desktop, hidden on mobile', async ({ + page, + }) => { + const viewport = page.viewportSize()! + const nav = page.getByTestId('dtf-nav') + + if (viewport.width < 1024) { + // Mobile: DTF header section hidden (hidden lg:flex) + // Nav links should still be accessible as icons + const links = nav.getByRole('link') + await expect(links.first()).toBeVisible() + } else { + // Desktop: DTF name visible in sidebar + await expect(nav.getByText('LCAP')).toBeVisible() + } + }) +}) + +test.describe('Responsive: Discover tabs', () => { + test('mobile tabs show compact layout', async ({ page }) => { + await page.goto('/') + await expect(page.getByTestId('discover-tabs')).toBeVisible({ + timeout: 10000, + }) + + const viewport = page.viewportSize()! + const tabs = page.getByTestId('discover-tabs') + + if (viewport.width < 1024) { + // Tab subtitles hidden on mobile (hidden lg:block) + // Tab triggers are compact (h-12 vs lg:h-[100px]) + const trigger = tabs.getByRole('tab').first() + const box = await trigger.boundingBox() + expect(box).toBeTruthy() + // Compact height on mobile + expect(box!.height).toBeLessThan(60) + } else { + // Desktop: tabs are taller with subtitles + const trigger = tabs.getByRole('tab').first() + const box = await trigger.boundingBox() + expect(box).toBeTruthy() + expect(box!.height).toBeGreaterThan(60) + } + }) +}) diff --git a/e2e/tests/ui-render.spec.ts b/e2e/tests/ui-render.spec.ts new file mode 100644 index 000000000..37ab6d58d --- /dev/null +++ b/e2e/tests/ui-render.spec.ts @@ -0,0 +1,287 @@ +import { test, expect } from '../fixtures/base' +import { TEST_DTFS } from '../helpers/test-data' + +const DTF_URL = `/base/index-dtf/${TEST_DTFS.lcap.address}/overview` + +test.describe('UI Render: Discover page data accuracy', () => { + test('renders exact number of DTFs from mock data', async ({ page }) => { + await page.goto('/') + await expect(page.getByTestId('discover-dtf-table')).toBeVisible({ + timeout: 10000, + }) + + // Mock has 3 DTFs: LCAP, CLX, AI + const rows = page.locator('table tbody tr') + await expect(rows).toHaveCount(3) + }) + + test('all DTF symbols from mock data are visible', async ({ page }) => { + await page.goto('/') + await expect(page.getByTestId('discover-dtf-table')).toBeVisible({ + timeout: 10000, + }) + + // Verify all 3 DTFs from discover-dtf.json render + await expect(page.getByText('LCAP').first()).toBeVisible() + await expect(page.getByText('CLX').first()).toBeVisible() + await expect(page.getByText('AI').first()).toBeVisible() + }) + + test('DTF market cap formats correctly with commas', async ({ page }) => { + await page.goto('/') + await expect(page.getByTestId('discover-dtf-table')).toBeVisible({ + timeout: 10000, + }) + + // LCAP market cap: 3539310.45 → "$3,539,310" + await expect(page.getByText('$3,539,310').first()).toBeVisible() + }) + + test('DTF tags render from brand data', async ({ page }) => { + await page.goto('/') + await expect(page.getByTestId('discover-dtf-table')).toBeVisible({ + timeout: 10000, + }) + + // LCAP has tags: ["Majors", "Bitcoin", "L1"] + await expect(page.getByText('Majors').first()).toBeVisible() + await expect(page.getByText('Bitcoin').first()).toBeVisible() + }) + + test('search clears with empty input', async ({ page }) => { + await page.goto('/') + await expect(page.getByTestId('discover-dtf-table')).toBeVisible({ + timeout: 10000, + }) + + const search = page.getByTestId('discover-search') + + // Filter to one result + await search.fill('LCAP') + await page.waitForTimeout(300) + await expect(page.locator('table tbody tr')).toHaveCount(1) + + // Clear search — all results should return + await search.fill('') + await page.waitForTimeout(300) + await expect(page.locator('table tbody tr')).toHaveCount(3) + }) +}) + +test.describe('UI Render: DTF overview data accuracy', () => { + test.beforeEach(async ({ page }) => { + await page.goto(DTF_URL) + await expect(page.getByText('LCAP').first()).toBeVisible({ + timeout: 10000, + }) + }) + + test('price from API displays with dollar sign', async ({ page }) => { + // API mock returns price: 1.25 + await expect(page.getByText('$1.25').first()).toBeVisible() + }) + + test('DTF full name renders from subgraph', async ({ page }) => { + // subgraph mock: token.name = "Large Cap Index DTF" + await expect( + page.getByText('Large Cap Index DTF').first() + ).toBeVisible() + }) + + test('brand description renders from folio-manager API', async ({ + page, + }) => { + await expect( + page + .getByText( + /diversified large cap index tracking the top crypto assets/ + ) + .first() + ).toBeVisible({ timeout: 10000 }) + }) + + test('creator name renders from brand data', async ({ page }) => { + // folio-manager mock: creator.name = "Reserve Protocol" + await expect( + page.getByText('Reserve Protocol').first() + ).toBeVisible({ timeout: 10000 }) + }) + + test('transaction table shows correct types from subgraph', async ({ + page, + }) => { + const txHeader = page.getByText('Transactions') + await txHeader.scrollIntoViewIfNeeded() + await expect(txHeader).toBeVisible({ timeout: 10000 }) + + // subgraph mock has 3 transfer events: 2 MINT, 1 REDEEM + await expect(page.getByText('Mint').first()).toBeVisible() + await expect(page.getByText('Redeem').first()).toBeVisible() + }) +}) + +test.describe('UI Render: Governance proposal rendering', () => { + test.beforeEach(async ({ page }) => { + await page.goto( + `/base/index-dtf/${TEST_DTFS.lcap.address}/governance` + ) + await expect(page.getByText('LCAP').first()).toBeVisible({ + timeout: 10000, + }) + }) + + test('renders exactly 3 proposals from subgraph mock', async ({ + page, + }) => { + const proposals = page.getByTestId('governance-proposals') + + // Wait for proposals to load + await expect( + proposals.getByText(/Update basket allocation/).first() + ).toBeVisible({ timeout: 10000 }) + + // Each proposal is a link element — should be exactly 3 + const proposalLinks = proposals.locator('a[href*="proposal/"]') + await expect(proposalLinks).toHaveCount(3) + }) + + test('proposal titles match subgraph mock data exactly', async ({ + page, + }) => { + const proposals = page.getByTestId('governance-proposals') + await expect( + proposals.getByText(/Update basket allocation/).first() + ).toBeVisible({ timeout: 10000 }) + + // Full proposal descriptions from mock + await expect( + proposals.getByText( + 'Update basket allocation to increase ETH weighting from 15% to 20%' + ) + ).toBeVisible() + await expect( + proposals.getByText('Reduce minting fee from 0.1% to 0.08%') + ).toBeVisible() + await expect( + proposals.getByText('Add new collateral type - Lido Staked Ether') + ).toBeVisible() + }) + + test('proposals are sorted by creation time (newest first)', async ({ + page, + }) => { + const proposals = page.getByTestId('governance-proposals') + await expect( + proposals.getByText(/Update basket allocation/).first() + ).toBeVisible({ timeout: 10000 }) + + // Get all proposal link texts in order + const proposalLinks = proposals.locator('a[href*="proposal/"]') + const count = await proposalLinks.count() + expect(count).toBe(3) + + // First proposal should be the most recent (Active - 1 day ago) + const firstProposal = await proposalLinks.nth(0).textContent() + expect(firstProposal).toContain('Update basket allocation') + + // Last proposal should be the oldest (Defeated - 14 days ago) + const lastProposal = await proposalLinks.nth(2).textContent() + expect(lastProposal).toContain('Add new collateral type') + }) + + test('proposals link to correct detail pages', async ({ page }) => { + const proposals = page.getByTestId('governance-proposals') + await expect( + proposals.getByText(/Update basket allocation/).first() + ).toBeVisible({ timeout: 10000 }) + + // Each proposal link should contain "proposal/" in href + const firstLink = proposals.locator('a[href*="proposal/"]').first() + const href = await firstLink.getAttribute('href') + expect(href).toContain('proposal/') + }) +}) + +test.describe('UI Render: Settings data accuracy', () => { + test.beforeEach(async ({ page }) => { + await page.goto( + `/base/index-dtf/${TEST_DTFS.lcap.address}/settings` + ) + await expect(page.getByText('LCAP').first()).toBeVisible({ + timeout: 10000, + }) + }) + + test('DTF name and mandate from subgraph render in settings', async ({ + page, + }) => { + const settings = page.getByTestId('dtf-settings') + + // Name from subgraph + await expect( + settings.getByText('Large Cap Index DTF').first() + ).toBeVisible() + + // Mandate from subgraph + await expect( + settings.getByText('Large cap diversified index').first() + ).toBeVisible() + }) + + test('settings page shows governance configuration', async ({ page }) => { + const settings = page.getByTestId('dtf-settings') + + // Governance-related labels should be present + await expect(settings.getByText('Basics').first()).toBeVisible() + await expect(settings.getByText('Ticker').first()).toBeVisible() + await expect(settings.getByText('Mandate').first()).toBeVisible() + }) +}) + +test.describe('UI Render: Manual issuance form state', () => { + test('mode toggle changes form labels and asset list header', async ({ + page, + }) => { + await page.goto( + `/base/index-dtf/${TEST_DTFS.lcap.address}/issuance/manual` + ) + await expect(page.getByText('LCAP').first()).toBeVisible({ + timeout: 10000, + }) + + // Default: buy mode + await expect(page.getByText('Mint Amount:').first()).toBeVisible() + await expect( + page.getByText('Required Approvals').first() + ).toBeVisible({ timeout: 10000 }) + + // Switch to sell + await page.getByRole('radio', { name: /sell/i }).click() + await expect(page.getByText('Redeem Amount:').first()).toBeVisible() + await expect( + page.getByText('You will receive').first() + ).toBeVisible() + + // Switch back to buy + await page.getByRole('radio', { name: /buy/i }).click() + await expect(page.getByText('Mint Amount:').first()).toBeVisible() + await expect( + page.getByText('Required Approvals').first() + ).toBeVisible() + }) + + test('amount input accepts numeric values', async ({ page }) => { + await page.goto( + `/base/index-dtf/${TEST_DTFS.lcap.address}/issuance/manual` + ) + await expect(page.getByText('LCAP').first()).toBeVisible({ + timeout: 10000, + }) + + const input = page.locator('input[placeholder="0"]').first() + await input.fill('100.5') + + // Value should be reflected + await expect(input).toHaveValue('100.5') + }) +}) diff --git a/e2e/tests/wallet.spec.ts b/e2e/tests/wallet.spec.ts new file mode 100644 index 000000000..32951d1a6 --- /dev/null +++ b/e2e/tests/wallet.spec.ts @@ -0,0 +1,26 @@ +import { test, expect } from '../fixtures/wallet' + +test.describe('Wallet connection', () => { + test('wallet auto-connects and shows address in header', async ({ page }) => { + await page.goto('/') + + // The mock wallet sets window.ethereum which wagmi detects. + // The wallet should auto-connect, showing the address instead of "Connect". + const walletDisplay = page.getByTestId('header-wallet') + await expect(walletDisplay).toBeVisible({ timeout: 15000 }) + + // Connect button should NOT be visible when connected + await expect(page.getByTestId('header-connect-btn')).not.toBeVisible() + }) + + test('connected state shows wallet icon and chain logo', async ({ page }) => { + await page.goto('/') + + // Wait for wallet to connect + const walletDisplay = page.getByTestId('header-wallet') + await expect(walletDisplay).toBeVisible({ timeout: 15000 }) + + // Wallet icon (lucide Wallet component) should be present + await expect(walletDisplay.locator('svg').first()).toBeVisible() + }) +}) diff --git a/package-lock.json b/package-lock.json index cbd81e941..8f9ce27b5 100644 --- a/package-lock.json +++ b/package-lock.json @@ -90,7 +90,7 @@ "@lingui/cli": "^5.8.0", "@lingui/vite-plugin": "^5.8.0", "@locator/babel-jsx": "^0.4.4", - "@playwright/test": "^1.57.0", + "@playwright/test": "^1.58.2", "@testing-library/jest-dom": "^5.16.4", "@testing-library/react": "^13.0.0", "@testing-library/user-event": "^14.0.4", @@ -4562,11 +4562,13 @@ "license": "MIT" }, "node_modules/@playwright/test": { - "version": "1.57.0", + "version": "1.58.2", + "resolved": "https://registry.npmjs.org/@playwright/test/-/test-1.58.2.tgz", + "integrity": "sha512-akea+6bHYBBfA9uQqSYmlJXn61cTa+jbO87xVLCWbTqbWadRVmhxlXATaOjOgcBaWU4ePo0wB41KMFv3o35IXA==", "devOptional": true, "license": "Apache-2.0", "dependencies": { - "playwright": "1.57.0" + "playwright": "1.58.2" }, "bin": { "playwright": "cli.js" @@ -17485,11 +17487,13 @@ } }, "node_modules/playwright": { - "version": "1.57.0", + "version": "1.58.2", + "resolved": "https://registry.npmjs.org/playwright/-/playwright-1.58.2.tgz", + "integrity": "sha512-vA30H8Nvkq/cPBnNw4Q8TWz1EJyqgpuinBcHET0YVJVFldr8JDNiU9LaWAE1KqSkRYazuaBhTpB5ZzShOezQ6A==", "devOptional": true, "license": "Apache-2.0", "dependencies": { - "playwright-core": "1.57.0" + "playwright-core": "1.58.2" }, "bin": { "playwright": "cli.js" @@ -17502,7 +17506,9 @@ } }, "node_modules/playwright-core": { - "version": "1.57.0", + "version": "1.58.2", + "resolved": "https://registry.npmjs.org/playwright-core/-/playwright-core-1.58.2.tgz", + "integrity": "sha512-yZkEtftgwS8CsfYo7nm0KE8jsvm6i/PTgVtB8DL726wNf6H2IMsDuxCpJj59KDaxCtSnrWan2AeDqM7JBaultg==", "devOptional": true, "license": "Apache-2.0", "bin": { @@ -17514,7 +17520,10 @@ }, "node_modules/playwright/node_modules/fsevents": { "version": "2.3.2", + "resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.2.tgz", + "integrity": "sha512-xiqMQR4xAeHTuB9uWm+fFRcIOgKBMiOBP+eXiyT7jsgVCq1bkVygt00oASowB7EdtpOHaaPgKt812P9ab+DDKA==", "dev": true, + "hasInstallScript": true, "license": "MIT", "optional": true, "os": [ diff --git a/package.json b/package.json index 303eeeb1f..c5cde3a2e 100644 --- a/package.json +++ b/package.json @@ -93,6 +93,7 @@ "test:run": "vitest run", "test:coverage": "vitest run --coverage", "test:e2e": "npx playwright test", + "test:e2e:ui": "npx playwright test --ui", "translations": "lingui extract --clean && lingui compile", "forknet": "anvil --fork-url https://eth.llamarpc.com --chain-id 1 --balance 10000000", "parse-collaterals": "node scripts/parseCollaterals", @@ -126,7 +127,7 @@ "@lingui/cli": "^5.8.0", "@lingui/vite-plugin": "^5.8.0", "@locator/babel-jsx": "^0.4.4", - "@playwright/test": "^1.57.0", + "@playwright/test": "^1.58.2", "@testing-library/jest-dom": "^5.16.4", "@testing-library/react": "^13.0.0", "@testing-library/user-event": "^14.0.4", @@ -152,4 +153,4 @@ "wrangler": "^4.59.3", "yaml": "^2.8.2" } -} \ No newline at end of file +} diff --git a/playwright.config.ts b/playwright.config.ts index 435d55bdd..42f8c134d 100644 --- a/playwright.config.ts +++ b/playwright.config.ts @@ -28,12 +28,15 @@ export default defineConfig({ /* Collect trace when retrying the failed test. See https://playwright.dev/docs/trace-viewer */ trace: 'on-first-retry', + + /* Capture screenshot on test failure for debugging */ + screenshot: 'only-on-failure', }, /* Configure projects for major browsers */ projects: [ { - name: 'chromium', + name: 'desktop', use: { ...devices['Desktop Chrome'], launchOptions: { @@ -41,6 +44,28 @@ export default defineConfig({ }, }, }, + { + name: 'mobile', + use: { + ...devices['Pixel 5'], + launchOptions: { + args: ['--disable-web-security'], + }, + }, + // Only run responsive test files on mobile + testMatch: /responsive/, + }, + { + name: 'tablet', + use: { + viewport: { width: 768, height: 1024 }, + launchOptions: { + args: ['--disable-web-security'], + }, + }, + // Only run responsive test files on tablet + testMatch: /responsive/, + }, // { // name: 'firefox', @@ -51,26 +76,6 @@ export default defineConfig({ // name: 'webkit', // use: { ...devices['Desktop Safari'] }, // }, - - /* Test against mobile viewports. */ - // { - // name: 'Mobile Chrome', - // use: { ...devices['Pixel 5'] }, - // }, - // { - // name: 'Mobile Safari', - // use: { ...devices['iPhone 12'] }, - // }, - - /* Test against branded browsers. */ - // { - // name: 'Microsoft Edge', - // use: { ...devices['Desktop Edge'], channel: 'msedge' }, - // }, - // { - // name: 'Google Chrome', - // use: { ...devices['Desktop Chrome'], channel: 'chrome' }, - // }, ], /* Run your local dev server before starting the tests */ diff --git a/src/components/account/index.tsx b/src/components/account/index.tsx index b127f0b12..672d999c6 100644 --- a/src/components/account/index.tsx +++ b/src/components/account/index.tsx @@ -29,6 +29,7 @@ const Account = () => ( if (!connected) { return (