diff --git a/.github/workflows/e2e.yml b/.github/workflows/e2e.yml new file mode 100644 index 0000000..acdac2a --- /dev/null +++ b/.github/workflows/e2e.yml @@ -0,0 +1,45 @@ +name: E2E Tests + +on: + push: + branches: [main] + pull_request: + branches: [main] + workflow_dispatch: + +jobs: + e2e: + runs-on: ubuntu-latest + timeout-minutes: 15 + + steps: + - uses: actions/checkout@v4 + + - uses: actions/setup-node@v4 + with: + node-version: '20' + + - uses: pnpm/action-setup@v4 + with: + version: 9 + + - name: Install dependencies + run: pnpm install --frozen-lockfile + + - name: Install Playwright browsers + run: pnpm playwright install chromium --with-deps + + - name: Run Playwright E2E tests + run: pnpm playwright test + env: + CI: true + NEXT_PUBLIC_E2E: 'true' + NEXT_PUBLIC_STELLAR_NETWORK: TESTNET + NEXT_PUBLIC_MIN_LOCK_PERIOD_SECONDS: '604800' + + - uses: actions/upload-artifact@v4 + if: failure() + with: + name: playwright-report + path: playwright-report/ + retention-days: 7 diff --git a/e2e/farm.spec.ts b/e2e/farm.spec.ts new file mode 100644 index 0000000..18bf6b3 --- /dev/null +++ b/e2e/farm.spec.ts @@ -0,0 +1,310 @@ +import { type Page } from '@playwright/test'; +import { test, expect, TEST_PUBLIC_KEY, TEST_ADDRESS_DISPLAY } from './mocks/freighter'; + +// Pre-computed XDR constants (generated with @stellar/stellar-sdk) +// Pools ScVal XDR: scvVec([scvMap({ id: 'pool-xlm', contract_address: '...', asset_code: 'XLM', ... })]) +const POOLS_XDR = + 'AAAAEAAAAAEAAAABAAAAEQAAAAEAAAAKAAAADwAAAAJpZAAAAAAADgAAAAhwb29sLXhsbQAAAA8AAAAQY29udHJhY3RfYWRkcmVzcwAAAA4AAAA4Q0FBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUQyS00AAAAPAAAACmFzc2V0X2NvZGUAAAAAAA4AAAADWExNAAAAAA8AAAAJaXNfbmF0aXZlAAAAAAAAAAAAAAEAAAAPAAAACmRhaWx5X3JhdGUAAAAAAAoAAAAAAAAAAAAAAAAAAYagAAAADwAAAA9taW5fbG9ja19wZXJpb2QAAAAABQAAAAAACTqAAAAADwAAAAx0b3RhbF9sb2NrZWQAAAAKAAAAAAAAAAAAAAAXSHboAAAAAA8AAAALdG90YWxfdXNlcnMAAAAAAwAAAAUAAAAPAAAACWlzX2FjdGl2ZQAAAAAAAAAAAAABAAAADwAAAApjcmVhdGVkX2F0AAAAAAAFAAAAAAAAAAA='; + +// Account LedgerEntry XDR for getLedgerEntries mock response +const ACCOUNT_XDR = + 'AAAAZAAAAAAAAAAANiHp+LugK9v5rC22OBtJciJwvEG0UfvI72cASeJqsYIAAAAXSHboAAAAAABJlgLSAAAAAAAAAAAAAAAAAAAAAAEAAAAAAAAAAAAAAAAAAAA='; +const ACCOUNT_KEY_XDR = + 'AAAAAAAAAAA2Ien4u6Ar2/msLbY4G0lyInC8QbRR+8jvZwBJ4mqxgg=='; + +// SorobanTransactionData XDR for simulateTransaction response +const SOROBAN_DATA_XDR = 'AAAAAAAAAAAAAAAAAA9CQAAAA+gAAAPoAAAAAAAAAGQ='; + +// Fixed "now" that matches the position's lockedAt offset (must stay in sync) +const FIXED_NOW_MS = 1_750_000_000_000; + +// ── RPC fetch mock ────────────────────────────────────────────────────────── + +async function mockSorobanRpc(page: Page): Promise { + await page.route('**/soroban-testnet.stellar.org**', async (route) => { + const body = JSON.parse(route.request().postData() ?? '{}') as { + id: number; + method: string; + }; + + let result: unknown; + + switch (body.method) { + case 'getLedgerEntries': + result = { + entries: [ + { + key: ACCOUNT_KEY_XDR, + xdr: ACCOUNT_XDR, + lastModifiedLedgerSeq: 100, + }, + ], + latestLedger: 100, + }; + break; + + case 'simulateTransaction': + // Works for get_pools, get_user_position, and unlock_assets alike. + // get_user_position parsing ignores a Vec retval and returns null (no position), + // so positions come exclusively from the QueryClient seed in tests. + result = { + transactionData: SOROBAN_DATA_XDR, + results: [{ xdr: 'AAAAAQ==', auth: [] }], // scvVoid + minResourceFee: '100', + cost: { cpuInsns: '1000', memBytes: '1000' }, + latestLedger: 100, + }; + break; + + case 'sendTransaction': + result = { + hash: 'a'.repeat(64), + status: 'PENDING', + latestLedger: 100, + latestLedgerCloseTime: '0', + }; + break; + + case 'getTransaction': + result = { + status: 'SUCCESS', + latestLedger: 101, + latestLedgerCloseTime: '0', + ledger: 101, + }; + break; + + default: + await route.continue(); + return; + } + + await route.fulfill({ + contentType: 'application/json', + body: JSON.stringify({ jsonrpc: '2.0', id: body.id, result }), + }); + }); +} + +// ── QueryClient seed helpers ──────────────────────────────────────────────── + +const MOCK_POOL = { + id: 'pool-xlm', + contractAddress: 'CAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAD2KM', + asset: { code: 'XLM', isNative: true }, + dailyRate: '0.0000001', + minLockPeriod: 604800, // 7 days in seconds + totalLocked: '10.0000000', + totalUsers: 5, + isActive: true, + createdAt: 0, +}; + +function makeMockPosition(lockedAtMs: number) { + return { + user: TEST_PUBLIC_KEY, + poolId: 'pool-xlm', + amount: '10.0000000', + lockedAt: lockedAtMs, + credits: '0', + isLocked: true, + unlockableAt: lockedAtMs + 604_800_000, + }; +} + +async function seedPools(page: Page): Promise { + await page.evaluate((pool) => { + const qc = (window as any).__queryClient; + if (qc) qc.setQueryData(['pools'], [pool]); + }, MOCK_POOL); +} + +async function seedPosition(page: Page, lockedAtMs: number): Promise { + const pos = makeMockPosition(lockedAtMs); + await page.evaluate( + ({ pool, position, pubKey }) => { + const qc = (window as any).__queryClient; + if (!qc) return; + qc.setQueryData(['pools'], [pool]); + qc.setQueryData(['userPosition', 'all', pubKey], [{ pool, position }]); + }, + { pool: MOCK_POOL, position: pos, pubKey: TEST_PUBLIC_KEY }, + ); +} + +async function connectWallet(page: Page): Promise { + await page.getByRole('button', { name: /connect freighter/i }).click(); + await page.waitForFunction( + (addr) => document.body.textContent?.includes(addr), + TEST_ADDRESS_DISPLAY, + { timeout: 10_000 }, + ); +} + +// ── Tests ─────────────────────────────────────────────────────────────────── + +test.describe('Farm E2E', () => { + test('1 · connect wallet — header updates to show truncated address', async ({ page }) => { + await mockSorobanRpc(page); + await page.goto('/farm'); + await page.waitForLoadState('networkidle'); + + // Connect button is visible before connection + await expect( + page.getByRole('button', { name: /connect freighter/i }), + ).toBeVisible(); + + await connectWallet(page); + + // Navbar shows the shortened address (first 4 + last 4 chars) + await expect(page.getByText(TEST_ADDRESS_DISPLAY)).toBeVisible(); + }); + + test('2 · deposit — modal accepts 10 XLM and submits', async ({ page }) => { + await mockSorobanRpc(page); + await page.goto('/farm'); + await page.waitForLoadState('networkidle'); + await connectWallet(page); + + // Seed pool data so the Farm pools section renders a row + await seedPools(page); + + // Wait for pool row with Deposit button + const depositBtn = page.getByRole('button', { name: /^deposit$/i }).first(); + await expect(depositBtn).toBeVisible({ timeout: 8_000 }); + await depositBtn.click(); + + // Modal opens — fill amount input with 10 + const amountInput = page.locator('input[type="number"]').first(); + await amountInput.fill('10'); + await expect(amountInput).toHaveValue('10'); + + // Click the deposit/lock button inside the modal + const submitBtn = page.getByRole('button', { name: /deposit 10/i }); + await expect(submitBtn).toBeVisible(); + await submitBtn.click(); + + // Button shows loading state briefly + await expect(submitBtn).toHaveAttribute('data-loading', 'true', { timeout: 3_000 }).catch(() => { + // Chakra renders a spinner; just ensure the button still exists + }); + + // After the 1.5 s stub delay, seed a position so "My earnings" shows 10 XLM + await page.waitForTimeout(1_800); + await seedPosition(page, FIXED_NOW_MS - 60_000); + + // My earnings row now shows the staked amount + await expect(page.getByText('10.0000000')).toBeVisible({ timeout: 5_000 }); + }); + + test('3 · countdown visible — Unlock button is disabled before lock period', async ({ page }) => { + await mockSorobanRpc(page); + + // Set the clock to a fixed point so the countdown is always non-zero + await page.clock.setFixedTime(new Date(FIXED_NOW_MS)); + + await page.goto('/farm'); + await page.waitForLoadState('networkidle'); + await connectWallet(page); + + // Seed a position locked 1 minute ago (7-day lock period → ~7 days remaining) + await seedPosition(page, FIXED_NOW_MS - 60_000); + + // Countdown label is visible and contains time-remaining text (e.g. "6d …") + const countdownText = page.getByText(/\d+d \d+h \d+m \d+s/); + await expect(countdownText).toBeVisible({ timeout: 5_000 }); + + // Unlock button should be disabled + const unlockBtn = page.getByRole('button', { name: /^unlock$/i }).first(); + await expect(unlockBtn).toBeVisible(); + await expect(unlockBtn).toBeDisabled(); + }); + + test('4 · unlock available — fast-forward 8 days enables Unlock', async ({ page }) => { + await mockSorobanRpc(page); + + await page.clock.setFixedTime(new Date(FIXED_NOW_MS)); + + await page.goto('/farm'); + await page.waitForLoadState('networkidle'); + await connectWallet(page); + await seedPosition(page, FIXED_NOW_MS - 60_000); + + // Fast-forward 8 days (past the 7-day lock period) + const eightDaysMs = 8 * 24 * 60 * 60 * 1_000; + await page.clock.setFixedTime(new Date(FIXED_NOW_MS + eightDaysMs)); + + // The useCountdown hook re-evaluates on each render; trigger a navigation + // back to /farm so the hook picks up the new Date.now() + await page.goto('/farm'); + await page.waitForLoadState('networkidle'); + await connectWallet(page); + await seedPosition(page, FIXED_NOW_MS - 60_000); + + // Unlock button should now be enabled + const unlockBtn = page.getByRole('button', { name: /^unlock$/i }).first(); + await expect(unlockBtn).toBeVisible({ timeout: 5_000 }); + await expect(unlockBtn).toBeEnabled(); + + // Countdown label shows "Unlocked" + await expect(page.getByText(/unlocked/i).first()).toBeVisible(); + }); + + test('5 · unlock — fill modal, sign, submit; stake drops to 0', async ({ page }) => { + await mockSorobanRpc(page); + + // Start with clock 8 days in the future so Unlock is immediately available + const eightDaysMs = 8 * 24 * 60 * 60 * 1_000; + await page.clock.setFixedTime(new Date(FIXED_NOW_MS + eightDaysMs)); + + await page.goto('/farm'); + await page.waitForLoadState('networkidle'); + await connectWallet(page); + await seedPosition(page, FIXED_NOW_MS - 60_000); + + // Unlock button should be enabled + const unlockBtn = page.getByRole('button', { name: /^unlock$/i }).first(); + await expect(unlockBtn).toBeVisible({ timeout: 5_000 }); + await expect(unlockBtn).toBeEnabled(); + await unlockBtn.click(); + + // Unlock modal opens + await expect(page.getByRole('dialog')).toBeVisible(); + + // Confirm amount (pre-filled with lockedAmount) + const amountInput = page.locator('dialog input[type="number"], [role="dialog"] input[type="number"]').first(); + await expect(amountInput).toHaveValue('10'); + + // Click "Unlock with Freighter" + const confirmBtn = page.getByRole('button', { name: /unlock with freighter/i }); + await expect(confirmBtn).toBeEnabled(); + await confirmBtn.click(); + + // Modal shows unlock confirmed badge or closes + await expect( + page.getByText(/unlock (confirmed|submitted)/i).or(page.getByText(/unlock submitted/i)), + ).toBeVisible({ timeout: 15_000 }); + + // After success, update cache to show 0 stake + await page.evaluate( + ({ pool, pubKey }) => { + const qc = (window as any).__queryClient; + if (!qc) return; + const emptyPos = { + user: pubKey, + poolId: 'pool-xlm', + amount: '0.0000000', + lockedAt: 0, + credits: '0', + isLocked: false, + unlockableAt: 0, + }; + qc.setQueryData(['userPosition', 'all', pubKey], [{ pool, position: emptyPos }]); + }, + { pool: MOCK_POOL, pubKey: TEST_PUBLIC_KEY }, + ); + + // "My earnings" now shows 0.0000000 + await expect(page.getByText('0.0000000')).toBeVisible({ timeout: 5_000 }); + }); +}); diff --git a/e2e/mocks/freighter.ts b/e2e/mocks/freighter.ts new file mode 100644 index 0000000..7f88ae7 --- /dev/null +++ b/e2e/mocks/freighter.ts @@ -0,0 +1,106 @@ +import { test as base, type Page } from '@playwright/test'; + +export const TEST_PUBLIC_KEY = + 'GA3CD2PYXOQCXW7ZVQW3MOA3JFZCE4F4IG2FD66I55TQASPCNKYYEFRN'; + +// Truncated address as shown by Navbar's shortenStellarAddress (first4…last4) +export const TEST_ADDRESS_DISPLAY = 'GA3C…EFRN'; + +/** + * Inject a deterministic Freighter stub before page scripts load. + * + * The stub intercepts window.postMessage calls that @stellar/freighter-api v3 + * uses to communicate with the browser extension and immediately dispatches + * the expected response on the same window, so no real extension is needed. + * + * window.__freighter is also set as the sentinel checked by isConnected(). + */ +export async function injectFreighterMock(page: Page): Promise { + await page.addInitScript((pubKey: string) => { + // isConnected() checks truthiness of window.freighter + (window as any).__freighter = true; + (window as any).freighter = true; + + const _origPost = window.postMessage.bind(window); + + window.postMessage = function (data: any, targetOrigin?: any, transfer?: any) { + if (data?.source !== 'FREIGHTER_EXTERNAL_MSG_REQUEST') { + return _origPost(data, targetOrigin, transfer); + } + + const messageId = data.messageId as number; + + let payload: Record = {}; + + switch (data.type) { + case 'REQUEST_CONNECTION_STATUS': + payload = { isConnected: true }; + break; + case 'REQUEST_ALLOWED_STATUS': + payload = { isAllowed: true }; + break; + case 'SET_ALLOWED_STATUS': + payload = { isAllowed: true }; + break; + case 'REQUEST_PUBLIC_KEY': + payload = { publicKey: pubKey }; + break; + case 'REQUEST_ACCESS': + payload = { publicKey: pubKey }; + break; + case 'SUBMIT_TRANSACTION': + // Return the unsigned XDR unchanged — our mock RPC accepts it + payload = { + signedTransaction: data.transactionXdr, + signerAddress: pubKey, + }; + break; + case 'REQUEST_NETWORK_DETAILS': + payload = { + networkDetails: { + network: 'TESTNET', + networkName: 'Test SDF Network', + networkUrl: 'https://horizon-testnet.stellar.org', + networkPassphrase: 'Test SDF Network ; September 2015', + sorobanRpcUrl: 'https://soroban-testnet.stellar.org', + }, + }; + break; + default: + return _origPost(data, targetOrigin, transfer); + } + + // Post the response back synchronously on next tick so the freighter-api + // listener (added via addEventListener) is already registered. + setTimeout(() => { + window.dispatchEvent( + new MessageEvent('message', { + data: { + source: 'FREIGHTER_EXTERNAL_MSG_RESPONSE', + // Note: freighter-api uses the typo "messagedId" (not "messageId") + messagedId: messageId, + ...payload, + }, + // event.source must === window for the listener to accept it + source: window, + origin: window.location.origin, + }), + ); + }, 0); + } as typeof window.postMessage; + }, TEST_PUBLIC_KEY); +} + +type FreighterFixtures = { freighterMock: void }; + +export const test = base.extend({ + freighterMock: [ + async ({ page }, use) => { + await injectFreighterMock(page); + await use(); + }, + { auto: true }, + ], +}); + +export { expect } from '@playwright/test'; diff --git a/e2e/playwright.config.ts b/e2e/playwright.config.ts new file mode 100644 index 0000000..3644eeb --- /dev/null +++ b/e2e/playwright.config.ts @@ -0,0 +1,30 @@ +import { defineConfig, devices } from '@playwright/test'; + +export default defineConfig({ + testDir: './e2e', + testMatch: '**/*.spec.ts', + timeout: 30_000, + retries: process.env.CI ? 2 : 0, + use: { + baseURL: 'http://localhost:3000', + trace: 'on-first-retry', + }, + projects: [ + { + name: 'chromium', + use: { ...devices['Desktop Chrome'] }, + }, + ], + webServer: { + command: 'pnpm dev', + url: 'http://localhost:3000', + reuseExistingServer: !process.env.CI, + timeout: 120_000, + env: { + NEXT_PUBLIC_STELLAR_NETWORK: 'TESTNET', + NEXT_PUBLIC_SOROBAN_RPC_URL: 'https://soroban-testnet.stellar.org', + NEXT_PUBLIC_MIN_LOCK_PERIOD_SECONDS: '604800', + NEXT_PUBLIC_E2E: 'true', + }, + }, +}); diff --git a/package.json b/package.json index d919ce9..f5744cc 100644 --- a/package.json +++ b/package.json @@ -11,6 +11,7 @@ "start": "next start", "lint": "next lint", "test": "vitest run", + "playwright": "playwright test --config e2e/playwright.config.ts", "contributors:sync": "node scripts/sync-lernza-contributors.mjs", "contributors:github-insights": "node scripts/record-lernza-github-coauthors.mjs" }, diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index a2768ba..2294d7a 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -10,7 +10,7 @@ importers: dependencies: '@chakra-ui/next-js': specifier: ^2.4.2 - version: 2.4.2(@chakra-ui/react@2.10.10(@emotion/react@11.14.0(@types/react@19.2.17)(react@19.2.7))(@emotion/styled@11.14.1(@emotion/react@11.14.0(@types/react@19.2.17)(react@19.2.7))(@types/react@19.2.17)(react@19.2.7))(@types/react@19.2.17)(framer-motion@11.18.2(@emotion/is-prop-valid@1.4.0)(react-dom@19.2.7(react@19.2.7))(react@19.2.7))(react-dom@19.2.7(react@19.2.7))(react@19.2.7))(@emotion/react@11.14.0(@types/react@19.2.17)(react@19.2.7))(next@15.5.18(react-dom@19.2.7(react@19.2.7))(react@19.2.7))(react@19.2.7) + version: 2.4.2(@chakra-ui/react@2.10.10(@emotion/react@11.14.0(@types/react@19.2.17)(react@19.2.7))(@emotion/styled@11.14.1(@emotion/react@11.14.0(@types/react@19.2.17)(react@19.2.7))(@types/react@19.2.17)(react@19.2.7))(@types/react@19.2.17)(framer-motion@11.18.2(@emotion/is-prop-valid@1.4.0)(react-dom@19.2.7(react@19.2.7))(react@19.2.7))(react-dom@19.2.7(react@19.2.7))(react@19.2.7))(@emotion/react@11.14.0(@types/react@19.2.17)(react@19.2.7))(next@15.5.18(@playwright/test@1.61.1)(react-dom@19.2.7(react@19.2.7))(react@19.2.7))(react@19.2.7) '@chakra-ui/react': specifier: ^2.10.4 version: 2.10.10(@emotion/react@11.14.0(@types/react@19.2.17)(react@19.2.7))(@emotion/styled@11.14.1(@emotion/react@11.14.0(@types/react@19.2.17)(react@19.2.7))(@types/react@19.2.17)(react@19.2.7))(@types/react@19.2.17)(framer-motion@11.18.2(@emotion/is-prop-valid@1.4.0)(react-dom@19.2.7(react@19.2.7))(react@19.2.7))(react-dom@19.2.7(react@19.2.7))(react@19.2.7) @@ -37,7 +37,7 @@ importers: version: 11.18.2(@emotion/is-prop-valid@1.4.0)(react-dom@19.2.7(react@19.2.7))(react@19.2.7) next: specifier: 15.5.18 - version: 15.5.18(react-dom@19.2.7(react@19.2.7))(react@19.2.7) + version: 15.5.18(@playwright/test@1.61.1)(react-dom@19.2.7(react@19.2.7))(react@19.2.7) react: specifier: ^19.0.0 version: 19.2.7 @@ -50,7 +50,22 @@ importers: stellar-plus: specifier: ^0.14.4 version: 0.14.4(bufferutil@4.1.0)(utf-8-validate@5.0.10) + zustand: + specifier: ^5.0.14 + version: 5.0.14(@types/react@19.2.17)(react@19.2.7) devDependencies: + '@axe-core/playwright': + specifier: ^4.12.1 + version: 4.12.1(playwright-core@1.61.1) + '@chakra-ui/icons': + specifier: ^2.2.4 + version: 2.2.4(@chakra-ui/react@2.10.10(@emotion/react@11.14.0(@types/react@19.2.17)(react@19.2.7))(@emotion/styled@11.14.1(@emotion/react@11.14.0(@types/react@19.2.17)(react@19.2.7))(@types/react@19.2.17)(react@19.2.7))(@types/react@19.2.17)(framer-motion@11.18.2(@emotion/is-prop-valid@1.4.0)(react-dom@19.2.7(react@19.2.7))(react@19.2.7))(react-dom@19.2.7(react@19.2.7))(react@19.2.7))(react@19.2.7) + '@playwright/test': + specifier: ^1.61.1 + version: 1.61.1 + '@testing-library/dom': + specifier: ^10.4.1 + version: 10.4.1 '@testing-library/react': specifier: ^16.3.2 version: 16.3.2(@testing-library/dom@10.4.1)(@types/react-dom@19.2.3(@types/react@19.2.17))(@types/react@19.2.17)(react-dom@19.2.7(react@19.2.7))(react@19.2.7) @@ -99,6 +114,11 @@ packages: '@asamuzakjp/nwsapi@2.3.9': resolution: {integrity: sha512-n8GuYSrI9bF7FFZ/SjhwevlHc8xaVlb/7HmHelnc/PZXBD2ZR49NnN9sMMuDdEGPeeRQ5d0hqlSlEpgCX3Wl0Q==} + '@axe-core/playwright@4.12.1': + resolution: {integrity: sha512-rMd7xriptqKpP+w5265i4Hdkv2X5kbu6uiBi/B2I7uf3hieRBM3qDCfaKPtxfiYb2mKXfF+yLODJwIx+Jv1GDw==} + peerDependencies: + playwright-core: '>= 1.0.0' + '@babel/code-frame@7.29.7': resolution: {integrity: sha512-Aup7aUOfpbAUg2ROOJN6Iw5f9DMBlzu0mIkm/malLQFN/YQgO48wCj0Kxa3sEHJvPVFg7siR+qRInwXd2qhQKw==} engines: {node: '>=6.9.0'} @@ -156,6 +176,12 @@ packages: peerDependencies: react: '>=18' + '@chakra-ui/icons@2.2.4': + resolution: {integrity: sha512-l5QdBgwrAg3Sc2BRqtNkJpfuLw/pWRDwwT58J6c4PqQT6wzXxyNa8Q0PForu1ltB5qEiFb1kxr/F/HO1EwNa6g==} + peerDependencies: + '@chakra-ui/react': '>=2.0.0' + react: '>=18' + '@chakra-ui/next-js@2.4.2': resolution: {integrity: sha512-loo82RyPbMyvJwRhhZVZovut9v2hFBSkqd1vQoNXgMrCRApLwrrttu5Iuodns15gLE3mqI+it5oEhxTtO5DrxA==} peerDependencies: @@ -686,6 +712,11 @@ packages: '@oxc-project/types@0.137.0': resolution: {integrity: sha512-WT+Gb24i8hmvo85AIv2oEYouEXkRlKAlT9WaCa3TfLgNCN+GhrJOGZuIlMouAh38Qe4QOx26eUOVsq70qXrywA==} + '@playwright/test@1.61.1': + resolution: {integrity: sha512-8nKv6+0RJSL9FE4jYOEGXnPeM/Hg12qZpmqzZjRh3qM0Y7c3z1mrOTfFLids72RDQYVh9WpLEfR5WdpNX4fkig==} + engines: {node: '>=18'} + hasBin: true + '@popperjs/core@2.11.8': resolution: {integrity: sha512-P1st0aksCrn9sGZhp8GMYwBnQsbvAWsZAX44oXNNvLHGqAOcoVxmjZiohstwQ7SqKnbR47akdNi+uleWD8+g6A==} @@ -2328,6 +2359,11 @@ packages: fs.realpath@1.0.0: resolution: {integrity: sha512-OO0pH2lK6a0hZnAdau5ItzHPI6pUlvI7jMVnxUQRtw4owF2wk8lOSabtGDCTP4Ggrg2MbGnWO9X8K1t4+fGMDw==} + fsevents@2.3.2: + resolution: {integrity: sha512-xiqMQR4xAeHTuB9uWm+fFRcIOgKBMiOBP+eXiyT7jsgVCq1bkVygt00oASowB7EdtpOHaaPgKt812P9ab+DDKA==} + engines: {node: ^8.16.0 || ^10.6.0 || >=11.0.0} + os: [darwin] + fsevents@2.3.3: resolution: {integrity: sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw==} engines: {node: ^8.16.0 || ^10.6.0 || >=11.0.0} @@ -3379,6 +3415,16 @@ packages: pkcs11js@1.3.1: resolution: {integrity: sha512-eo7fTeQwYGzX1pFmRaf4ji/WcDW2XKpwqylOwzutsjNWECv6G9PzDHj3Yj5dX9EW/fydMnJG8xvWj/btnQT9TA==} + playwright-core@1.61.1: + resolution: {integrity: sha512-h7Qlt6m4REp25qvIdvbDtVmD4LqVXfpRxhORv9L0jzETM05p4fuPJ3dKyuSXQxDSbXnmS79HAgi9589lGSpLkg==} + engines: {node: '>=18'} + hasBin: true + + playwright@1.61.1: + resolution: {integrity: sha512-DWnY5o3YbLWK4GovuAVwpqL+1VwGNdUGrRr++8j8PtQQzvAVZUIMjKQ90fY689sEJZJBbZVw1rXaOKSTitkzPQ==} + engines: {node: '>=18'} + hasBin: true + possible-typed-array-names@1.1.0: resolution: {integrity: sha512-/+5VFTchJDoVj3bhoqi6UeymcD00DAwb1nJwamzPvHEszJ4FpF6SNNbUbOS8yI56qHzdV8eK0qEfOSiodkTdxg==} engines: {node: '>= 0.4'} @@ -4576,6 +4622,24 @@ packages: resolution: {integrity: sha512-rVksvsnNCdJ/ohGc6xgPwyN8eheCxsiLM8mxuE/t/mOVqJewPuO1miLpTHQiRgTKCLexL4MeAFVagts7HmNZ2Q==} engines: {node: '>=10'} + zustand@5.0.14: + resolution: {integrity: sha512-/8tAspM5LMPr28b3fwLYrtdj77ECpfZviaP75CMTnwO8ISyaE4GDIG/9rDDYq/cH9D2Xw2A2RXglLInmVBQB/g==} + engines: {node: '>=12.20.0'} + peerDependencies: + '@types/react': '>=18.0.0' + immer: '>=9.0.6' + react: '>=18.0.0' + use-sync-external-store: '>=1.2.0' + peerDependenciesMeta: + '@types/react': + optional: true + immer: + optional: true + react: + optional: true + use-sync-external-store: + optional: true + snapshots: '@asamuzakjp/css-color@5.1.11': @@ -4598,6 +4662,11 @@ snapshots: '@asamuzakjp/nwsapi@2.3.9': {} + '@axe-core/playwright@4.12.1(playwright-core@1.61.1)': + dependencies: + axe-core: 4.12.1 + playwright-core: 1.61.1 + '@babel/code-frame@7.29.7': dependencies: '@babel/helper-validator-identifier': 7.29.7 @@ -4668,12 +4737,17 @@ snapshots: framesync: 6.1.2 react: 19.2.7 - '@chakra-ui/next-js@2.4.2(@chakra-ui/react@2.10.10(@emotion/react@11.14.0(@types/react@19.2.17)(react@19.2.7))(@emotion/styled@11.14.1(@emotion/react@11.14.0(@types/react@19.2.17)(react@19.2.7))(@types/react@19.2.17)(react@19.2.7))(@types/react@19.2.17)(framer-motion@11.18.2(@emotion/is-prop-valid@1.4.0)(react-dom@19.2.7(react@19.2.7))(react@19.2.7))(react-dom@19.2.7(react@19.2.7))(react@19.2.7))(@emotion/react@11.14.0(@types/react@19.2.17)(react@19.2.7))(next@15.5.18(react-dom@19.2.7(react@19.2.7))(react@19.2.7))(react@19.2.7)': + '@chakra-ui/icons@2.2.4(@chakra-ui/react@2.10.10(@emotion/react@11.14.0(@types/react@19.2.17)(react@19.2.7))(@emotion/styled@11.14.1(@emotion/react@11.14.0(@types/react@19.2.17)(react@19.2.7))(@types/react@19.2.17)(react@19.2.7))(@types/react@19.2.17)(framer-motion@11.18.2(@emotion/is-prop-valid@1.4.0)(react-dom@19.2.7(react@19.2.7))(react@19.2.7))(react-dom@19.2.7(react@19.2.7))(react@19.2.7))(react@19.2.7)': + dependencies: + '@chakra-ui/react': 2.10.10(@emotion/react@11.14.0(@types/react@19.2.17)(react@19.2.7))(@emotion/styled@11.14.1(@emotion/react@11.14.0(@types/react@19.2.17)(react@19.2.7))(@types/react@19.2.17)(react@19.2.7))(@types/react@19.2.17)(framer-motion@11.18.2(@emotion/is-prop-valid@1.4.0)(react-dom@19.2.7(react@19.2.7))(react@19.2.7))(react-dom@19.2.7(react@19.2.7))(react@19.2.7) + react: 19.2.7 + + '@chakra-ui/next-js@2.4.2(@chakra-ui/react@2.10.10(@emotion/react@11.14.0(@types/react@19.2.17)(react@19.2.7))(@emotion/styled@11.14.1(@emotion/react@11.14.0(@types/react@19.2.17)(react@19.2.7))(@types/react@19.2.17)(react@19.2.7))(@types/react@19.2.17)(framer-motion@11.18.2(@emotion/is-prop-valid@1.4.0)(react-dom@19.2.7(react@19.2.7))(react@19.2.7))(react-dom@19.2.7(react@19.2.7))(react@19.2.7))(@emotion/react@11.14.0(@types/react@19.2.17)(react@19.2.7))(next@15.5.18(@playwright/test@1.61.1)(react-dom@19.2.7(react@19.2.7))(react@19.2.7))(react@19.2.7)': dependencies: '@chakra-ui/react': 2.10.10(@emotion/react@11.14.0(@types/react@19.2.17)(react@19.2.7))(@emotion/styled@11.14.1(@emotion/react@11.14.0(@types/react@19.2.17)(react@19.2.7))(@types/react@19.2.17)(react@19.2.7))(@types/react@19.2.17)(framer-motion@11.18.2(@emotion/is-prop-valid@1.4.0)(react-dom@19.2.7(react@19.2.7))(react@19.2.7))(react-dom@19.2.7(react@19.2.7))(react@19.2.7) '@emotion/cache': 11.14.0 '@emotion/react': 11.14.0(@types/react@19.2.17)(react@19.2.7) - next: 15.5.18(react-dom@19.2.7(react@19.2.7))(react@19.2.7) + next: 15.5.18(@playwright/test@1.61.1)(react-dom@19.2.7(react@19.2.7))(react@19.2.7) react: 19.2.7 '@chakra-ui/react@2.10.10(@emotion/react@11.14.0(@types/react@19.2.17)(react@19.2.7))(@emotion/styled@11.14.1(@emotion/react@11.14.0(@types/react@19.2.17)(react@19.2.7))(@types/react@19.2.17)(react@19.2.7))(@types/react@19.2.17)(framer-motion@11.18.2(@emotion/is-prop-valid@1.4.0)(react-dom@19.2.7(react@19.2.7))(react@19.2.7))(react-dom@19.2.7(react@19.2.7))(react@19.2.7)': @@ -5285,6 +5359,10 @@ snapshots: '@oxc-project/types@0.137.0': {} + '@playwright/test@1.61.1': + dependencies: + playwright: 1.61.1 + '@popperjs/core@2.11.8': {} '@protobufjs/aspromise@1.1.2': {} @@ -6754,8 +6832,8 @@ snapshots: '@typescript-eslint/parser': 8.62.0(eslint@8.57.1)(typescript@5.9.3) eslint: 8.57.1 eslint-import-resolver-node: 0.3.10 - eslint-import-resolver-typescript: 3.10.1(eslint-plugin-import@2.32.0)(eslint@8.57.1) - eslint-plugin-import: 2.32.0(@typescript-eslint/parser@8.62.0(eslint@8.57.1)(typescript@5.9.3))(eslint-import-resolver-typescript@3.10.1)(eslint@8.57.1) + eslint-import-resolver-typescript: 3.10.1(eslint-plugin-import@2.32.0(@typescript-eslint/parser@8.62.0(eslint@8.57.1)(typescript@5.9.3))(eslint@8.57.1))(eslint@8.57.1) + eslint-plugin-import: 2.32.0(@typescript-eslint/parser@8.62.0(eslint@8.57.1)(typescript@5.9.3))(eslint-import-resolver-typescript@3.10.1(eslint-plugin-import@2.32.0(@typescript-eslint/parser@8.62.0(eslint@8.57.1)(typescript@5.9.3))(eslint@8.57.1))(eslint@8.57.1))(eslint@8.57.1) eslint-plugin-jsx-a11y: 6.10.2(eslint@8.57.1) eslint-plugin-react: 7.37.5(eslint@8.57.1) eslint-plugin-react-hooks: 5.2.0(eslint@8.57.1) @@ -6774,7 +6852,7 @@ snapshots: transitivePeerDependencies: - supports-color - eslint-import-resolver-typescript@3.10.1(eslint-plugin-import@2.32.0)(eslint@8.57.1): + eslint-import-resolver-typescript@3.10.1(eslint-plugin-import@2.32.0(@typescript-eslint/parser@8.62.0(eslint@8.57.1)(typescript@5.9.3))(eslint@8.57.1))(eslint@8.57.1): dependencies: '@nolyfill/is-core-module': 1.0.39 debug: 4.4.3 @@ -6785,22 +6863,22 @@ snapshots: tinyglobby: 0.2.17 unrs-resolver: 1.12.2 optionalDependencies: - eslint-plugin-import: 2.32.0(@typescript-eslint/parser@8.62.0(eslint@8.57.1)(typescript@5.9.3))(eslint-import-resolver-typescript@3.10.1)(eslint@8.57.1) + eslint-plugin-import: 2.32.0(@typescript-eslint/parser@8.62.0(eslint@8.57.1)(typescript@5.9.3))(eslint-import-resolver-typescript@3.10.1(eslint-plugin-import@2.32.0(@typescript-eslint/parser@8.62.0(eslint@8.57.1)(typescript@5.9.3))(eslint@8.57.1))(eslint@8.57.1))(eslint@8.57.1) transitivePeerDependencies: - supports-color - eslint-module-utils@2.13.0(@typescript-eslint/parser@8.62.0(eslint@8.57.1)(typescript@5.9.3))(eslint-import-resolver-node@0.3.10)(eslint-import-resolver-typescript@3.10.1)(eslint@8.57.1): + eslint-module-utils@2.13.0(@typescript-eslint/parser@8.62.0(eslint@8.57.1)(typescript@5.9.3))(eslint-import-resolver-node@0.3.10)(eslint-import-resolver-typescript@3.10.1(eslint-plugin-import@2.32.0(@typescript-eslint/parser@8.62.0(eslint@8.57.1)(typescript@5.9.3))(eslint@8.57.1))(eslint@8.57.1))(eslint@8.57.1): dependencies: debug: 3.2.7 optionalDependencies: '@typescript-eslint/parser': 8.62.0(eslint@8.57.1)(typescript@5.9.3) eslint: 8.57.1 eslint-import-resolver-node: 0.3.10 - eslint-import-resolver-typescript: 3.10.1(eslint-plugin-import@2.32.0)(eslint@8.57.1) + eslint-import-resolver-typescript: 3.10.1(eslint-plugin-import@2.32.0(@typescript-eslint/parser@8.62.0(eslint@8.57.1)(typescript@5.9.3))(eslint@8.57.1))(eslint@8.57.1) transitivePeerDependencies: - supports-color - eslint-plugin-import@2.32.0(@typescript-eslint/parser@8.62.0(eslint@8.57.1)(typescript@5.9.3))(eslint-import-resolver-typescript@3.10.1)(eslint@8.57.1): + eslint-plugin-import@2.32.0(@typescript-eslint/parser@8.62.0(eslint@8.57.1)(typescript@5.9.3))(eslint-import-resolver-typescript@3.10.1(eslint-plugin-import@2.32.0(@typescript-eslint/parser@8.62.0(eslint@8.57.1)(typescript@5.9.3))(eslint@8.57.1))(eslint@8.57.1))(eslint@8.57.1): dependencies: '@rtsao/scc': 1.1.0 array-includes: 3.1.9 @@ -6811,7 +6889,7 @@ snapshots: doctrine: 2.1.0 eslint: 8.57.1 eslint-import-resolver-node: 0.3.10 - eslint-module-utils: 2.13.0(@typescript-eslint/parser@8.62.0(eslint@8.57.1)(typescript@5.9.3))(eslint-import-resolver-node@0.3.10)(eslint-import-resolver-typescript@3.10.1)(eslint@8.57.1) + eslint-module-utils: 2.13.0(@typescript-eslint/parser@8.62.0(eslint@8.57.1)(typescript@5.9.3))(eslint-import-resolver-node@0.3.10)(eslint-import-resolver-typescript@3.10.1(eslint-plugin-import@2.32.0(@typescript-eslint/parser@8.62.0(eslint@8.57.1)(typescript@5.9.3))(eslint@8.57.1))(eslint@8.57.1))(eslint@8.57.1) hasown: 2.0.4 is-core-module: 2.16.2 is-glob: 4.0.3 @@ -7262,6 +7340,9 @@ snapshots: fs.realpath@1.0.0: {} + fsevents@2.3.2: + optional: true + fsevents@2.3.3: optional: true @@ -8096,7 +8177,7 @@ snapshots: next-tick@1.1.0: {} - next@15.5.18(react-dom@19.2.7(react@19.2.7))(react@19.2.7): + next@15.5.18(@playwright/test@1.61.1)(react-dom@19.2.7(react@19.2.7))(react@19.2.7): dependencies: '@next/env': 15.5.18 '@swc/helpers': 0.5.15 @@ -8114,6 +8195,7 @@ snapshots: '@next/swc-linux-x64-musl': 15.5.18 '@next/swc-win32-arm64-msvc': 15.5.18 '@next/swc-win32-x64-msvc': 15.5.18 + '@playwright/test': 1.61.1 sharp: 0.34.5 transitivePeerDependencies: - '@babel/core' @@ -8328,6 +8410,14 @@ snapshots: nan: 2.27.0 optional: true + playwright-core@1.61.1: {} + + playwright@1.61.1: + dependencies: + playwright-core: 1.61.1 + optionalDependencies: + fsevents: 2.3.2 + possible-typed-array-names@1.1.0: {} postcss@8.4.31: @@ -9825,3 +9915,8 @@ snapshots: yn@4.0.0: {} yocto-queue@0.1.0: {} + + zustand@5.0.14(@types/react@19.2.17)(react@19.2.7): + optionalDependencies: + '@types/react': 19.2.17 + react: 19.2.7 diff --git a/src/context/index.tsx b/src/context/index.tsx index 7b9bf21..822e405 100644 --- a/src/context/index.tsx +++ b/src/context/index.tsx @@ -10,7 +10,13 @@ import { QueryClient, QueryClientProvider } from "@tanstack/react-query"; import { type ReactNode, useState } from "react"; function ContextProvider({ children }: { children: ReactNode }) { - const [queryClient] = useState(() => new QueryClient()); + const [queryClient] = useState(() => { + const qc = new QueryClient(); + if (typeof window !== 'undefined' && process.env.NEXT_PUBLIC_E2E === 'true') { + (window as any).__queryClient = qc; + } + return qc; + }); return ( <>