import { expect, test, type Page } from '@playwright/test'; import { createWalletContext } from '../setup/wallet-provider'; import { getStackConfig, validateStackHealthy } from '../setup/stack'; import { navigateSPA } from '../setup/navigate'; const ACCOUNT_PRIVATE_KEY = '0xac0974bec39a17e36ba4a6b4d238ff944bacb478cbed5efcae784d7bf4f2ff80'; const STACK_CONFIG = getStackConfig(); const STACK_WEBAPP_URL = STACK_CONFIG.webAppUrl; const STACK_RPC_URL = STACK_CONFIG.rpcUrl; // Expected Uniswap deep link components for Base mainnet const BASE_MAINNET_TOKEN = '0x45caa5929f6ee038039984205bdecf968b954820'; /** * Inject a mock window.umami tracker that records events into a global array. * Must be called via context.addInitScript so it runs before app code. */ function analyticsCollectorScript(): string { return ` window.__analytics_events = []; window.umami = { track: function(name, data) { window.__analytics_events.push({ name: name, data: data || {} }); } }; `; } /** Retrieve collected analytics events from the page. */ async function getAnalyticsEvents(page: Page): Promise }>> { return page.evaluate(() => (window as unknown as { __analytics_events: Array<{ name: string; data: Record }> }).__analytics_events ?? []); } test.describe('Conversion Funnel: Landing → Swap → Stake', () => { // Cap per-test timeout to 3 minutes — funnel tests are navigation-only, no transactions. test.describe.configure({ timeout: 3 * 60 * 1000 }); test.beforeAll(async () => { await validateStackHealthy(STACK_CONFIG); }); test('desktop: full funnel navigation and deep link verification', async ({ browser }) => { // Desktop viewport (matches playwright.config default) const context = await browser.newContext({ viewport: { width: 1280, height: 720 }, screen: { width: 1280, height: 720 }, }); // Inject analytics collector before any page loads await context.addInitScript({ content: analyticsCollectorScript() }); const page = await context.newPage(); page.on('console', msg => console.log(`[BROWSER] ${msg.type()}: ${msg.text()}`)); try { // ── Step 1: Landing page loads ── console.log('[FUNNEL] Loading landing page...'); await page.goto(`${STACK_WEBAPP_URL}/`, { waitUntil: 'domcontentloaded' }); // Verify landing page rendered — look for the hero CTA const heroCta = page.locator('.header-cta button, .header-cta .k-button').first(); await expect(heroCta).toBeVisible({ timeout: 30_000 }); console.log('[FUNNEL] Landing page loaded, hero CTA visible'); await page.screenshot({ path: 'test-results/funnel-01-landing-desktop.png' }); // ── Step 2: Click CTA → navigates to /app/get-krk ── console.log('[FUNNEL] Clicking hero "Get $KRK" CTA...'); // Use Promise.all to avoid race: the click triggers window.location.href // which starts a full-page navigation through Caddy to the webapp. await Promise.all([ page.waitForURL('**/app/get-krk**', { timeout: 30_000 }), heroCta.click(), ]); console.log('[FUNNEL] Navigated to web-app get-krk page'); // Verify the Get KRK page rendered const getKrkHeading = page.getByRole('heading', { name: 'Get $KRK Tokens' }); await expect(getKrkHeading).toBeVisible({ timeout: 15_000 }); await page.screenshot({ path: 'test-results/funnel-02-get-krk-desktop.png' }); // ── Step 3: Verify swap page content ── console.log('[FUNNEL] Verifying swap page...'); // In local dev (VITE_ENABLE_LOCAL_SWAP=true), the LocalSwapWidget is shown. // Without a wallet, it renders a "Connect your wallet" message instead of // the swap input. Check for the widget container class to detect local mode. const localSwapWidget = page.locator('.local-swap-widget'); const hasLocalSwap = await localSwapWidget.isVisible({ timeout: 5_000 }).catch(() => false); if (hasLocalSwap) { console.log('[FUNNEL] Local swap widget detected (dev environment)'); // Without wallet, the widget shows a connect prompt const swapWarning = page.locator('.swap-warning'); const hasWarning = await swapWarning.isVisible({ timeout: 3_000 }).catch(() => false); if (hasWarning) { console.log('[FUNNEL] Wallet not connected — swap widget shows connect prompt'); } else { // With wallet, the swap input is shown await expect(page.locator('[data-testid="swap-amount-input"]')).toBeVisible({ timeout: 5_000 }); console.log('[FUNNEL] Swap input visible'); } } else { // Production path: verify the Uniswap deep link const swapLink = page.locator('a.swap-button'); await expect(swapLink).toBeVisible({ timeout: 10_000 }); const href = await swapLink.getAttribute('href'); expect(href).toBeTruthy(); console.log(`[FUNNEL] Uniswap link: ${href}`); // Verify link points to Uniswap with correct structure expect(href).toContain('app.uniswap.org/swap'); expect(href).toContain('outputCurrency='); // Verify the link opens in a new tab (doesn't navigate away) const target = await swapLink.getAttribute('target'); expect(target).toBe('_blank'); const rel = await swapLink.getAttribute('rel'); expect(rel).toContain('noopener'); // For Base mainnet deployments, verify exact token address if (href!.includes('chain=base&')) { expect(href).toContain(`outputCurrency=${BASE_MAINNET_TOKEN}`); console.log('[FUNNEL] ✅ Base mainnet token address verified'); } // Verify network info is displayed const networkLabel = page.locator('.info-value').first(); await expect(networkLabel).toBeVisible(); } // ── Step 4: Navigate to stake page ── console.log('[FUNNEL] Navigating to stake page...'); await navigateSPA(page, '/app/stake'); // Stake page may redirect to /app/login if no wallet connected. // Wait for either page's root element to confirm the route has mounted. await page.waitForSelector('.stake-view, .login-wrapper', { timeout: 15_000 }); // Either outcome confirms the route is functional. const currentUrl = page.url(); const isStakePage = currentUrl.includes('/app/stake'); const isLoginPage = currentUrl.includes('/app/login'); expect(isStakePage || isLoginPage).toBeTruthy(); console.log(`[FUNNEL] Stake route resolved to: ${currentUrl}`); await page.screenshot({ path: 'test-results/funnel-03-stake-desktop.png' }); console.log('[FUNNEL] ✅ Desktop funnel navigation complete'); } finally { await context.close(); } }); test('mobile: full funnel navigation and deep link verification', async ({ browser }) => { // iPhone-like viewport const context = await browser.newContext({ viewport: { width: 375, height: 812 }, screen: { width: 375, height: 812 }, isMobile: true, }); await context.addInitScript({ content: analyticsCollectorScript() }); const page = await context.newPage(); page.on('console', msg => console.log(`[BROWSER] ${msg.type()}: ${msg.text()}`)); try { // ── Step 1: Landing page loads on mobile ── console.log('[FUNNEL-MOBILE] Loading landing page...'); await page.goto(`${STACK_WEBAPP_URL}/`, { waitUntil: 'domcontentloaded' }); // Verify mobile header image is shown const mobileHeader = page.locator('.header-section img').first(); await expect(mobileHeader).toBeVisible({ timeout: 30_000 }); console.log('[FUNNEL-MOBILE] Landing page loaded'); await page.screenshot({ path: 'test-results/funnel-01-landing-mobile.png' }); // ── Step 2: Verify CTA exists, then navigate to /app/get-krk ── const heroCta = page.locator('.header-cta button').first(); await expect(heroCta).toBeVisible({ timeout: 15_000 }); console.log('[FUNNEL-MOBILE] Hero CTA visible on mobile viewport'); // Navigate directly to the get-krk page. On mobile with isMobile:true, // Playwright tap events don't reliably trigger Vue @click handlers that // set window.location.href. The desktop test already verifies the CTA // click→navigation flow; here we verify mobile layout and page rendering. await page.goto(`${STACK_WEBAPP_URL}/app/get-krk`, { waitUntil: 'domcontentloaded' }); console.log('[FUNNEL-MOBILE] Navigated to get-krk page'); const getKrkHeading = page.getByRole('heading', { name: 'Get $KRK Tokens' }); await expect(getKrkHeading).toBeVisible({ timeout: 15_000 }); await page.screenshot({ path: 'test-results/funnel-02-get-krk-mobile.png' }); // ── Step 3: Verify swap page on mobile ── const localSwapWidget = page.locator('.local-swap-widget'); const hasLocalSwap = await localSwapWidget.isVisible({ timeout: 5_000 }).catch(() => false); if (hasLocalSwap) { console.log('[FUNNEL-MOBILE] Local swap widget detected'); } else { const swapLink = page.locator('a.swap-button'); await expect(swapLink).toBeVisible({ timeout: 10_000 }); const href = await swapLink.getAttribute('href'); expect(href).toContain('app.uniswap.org/swap'); console.log('[FUNNEL-MOBILE] Uniswap deep link verified on mobile'); } // ── Step 4: Navigate to stake ── await navigateSPA(page, '/app/stake'); // Wait for either page's root element — stake or login redirect. await page.waitForSelector('.stake-view, .login-wrapper', { timeout: 15_000 }); const currentUrl = page.url(); expect(currentUrl.includes('/app/stake') || currentUrl.includes('/app/login')).toBeTruthy(); await page.screenshot({ path: 'test-results/funnel-03-stake-mobile.png' }); console.log('[FUNNEL-MOBILE] ✅ Mobile funnel navigation complete'); } finally { await context.close(); } }); test('analytics infrastructure: collector available in landing and web-app contexts', async ({ browser }) => { // Verifies that the addInitScript mock collector is active in both apps and that // the umami tracker object is present. Note: this test confirms the collector // infrastructure (addInitScript wiring, umami availability) — not that the app // source code calls trackCtaClick on a real CTA click, since full-page navigation // unloads the page context before events can be read. const context = await createWalletContext(browser, { privateKey: ACCOUNT_PRIVATE_KEY, rpcUrl: STACK_RPC_URL, }); await context.addInitScript({ content: analyticsCollectorScript() }); const page = await context.newPage(); page.on('console', msg => console.log(`[BROWSER] ${msg.type()}: ${msg.text()}`)); try { // ── Landing page: verify addInitScript collector is active ── console.log('[ANALYTICS] Loading landing page...'); await page.goto(`${STACK_WEBAPP_URL}/`, { waitUntil: 'domcontentloaded' }); const heroCta = page.locator('.header-cta button, .header-cta .k-button').first(); await expect(heroCta).toBeVisible({ timeout: 30_000 }); // Confirm the collector mock is live by recording a synthetic event and reading it back. await page.evaluate(() => { (window as unknown as { umami: { track: (n: string, d: Record) => void } }) .umami.track('cta_click', { label: 'hero_get_krk' }); }); const landingEvents = await getAnalyticsEvents(page); console.log(`[ANALYTICS] Landing page events captured: ${JSON.stringify(landingEvents)}`); const ctaEvent = landingEvents.find(e => e.name === 'cta_click'); expect(ctaEvent).toBeTruthy(); expect(ctaEvent!.data.label).toBe('hero_get_krk'); console.log('[ANALYTICS] ✅ Collector active on landing page'); // ── Web-app: verify umami tracker is available ── await page.goto(`${STACK_WEBAPP_URL}/app/get-krk`, { waitUntil: 'domcontentloaded' }); await expect(page.getByRole('heading', { name: 'Get $KRK Tokens' })).toBeVisible({ timeout: 15_000 }); const hasUmami = await page.evaluate(() => typeof (window as unknown as { umami?: unknown }).umami !== 'undefined'); expect(hasUmami).toBeTruthy(); console.log('[ANALYTICS] ✅ umami tracker available in web-app context'); console.log('[ANALYTICS] ✅ Analytics infrastructure verified'); } finally { await context.close(); } }); test('Uniswap deep link structure for all configured chains', async ({ browser }) => { // Verify deep link construction logic matches expected patterns. // This test loads the get-krk page and checks the link format. const context = await createWalletContext(browser, { privateKey: ACCOUNT_PRIVATE_KEY, rpcUrl: STACK_RPC_URL, }); const page = await context.newPage(); try { console.log('[DEEPLINK] Loading get-krk page...'); await page.goto(`${STACK_WEBAPP_URL}/app/get-krk`, { waitUntil: 'domcontentloaded' }); await expect(page.getByRole('heading', { name: 'Get $KRK Tokens' })).toBeVisible({ timeout: 15_000 }); // Check if we're in local swap mode or production link mode. // The LocalSwapWidget container is always rendered when enableLocalSwap=true, // but the swap input only appears when a wallet is connected. const localSwapWidget = page.locator('.local-swap-widget'); const hasLocalSwap = await localSwapWidget.isVisible({ timeout: 5_000 }).catch(() => false); if (hasLocalSwap) { console.log('[DEEPLINK] Local swap mode — connecting wallet first'); // The wallet must be explicitly connected for the swap input to render. // Navigate to main page to access the connect button, then return. const navbarTitle = page.locator('.navbar-title').first(); await navbarTitle.waitFor({ state: 'visible', timeout: 30_000 }); await page.evaluate(() => window.dispatchEvent(new Event('resize'))); const connectBtn = page.locator('.connect-button--disconnected').first(); if (await connectBtn.isVisible({ timeout: 10_000 }).catch(() => false)) { await connectBtn.click(); const connector = page.locator('.connectors-element').first(); await connector.waitFor({ state: 'visible', timeout: 10_000 }); await connector.click(); // Wait for wallet to connect await page.getByText(/0x[a-fA-F0-9]{4}/i).first().waitFor({ state: 'visible', timeout: 15_000 }); console.log('[DEEPLINK] Wallet connected'); } console.log('[DEEPLINK] Verifying swap widget is functional'); // With wallet connected, the swap input and buy button should be visible await expect(page.locator('[data-testid="swap-amount-input"]')).toBeVisible({ timeout: 5_000 }); await expect(page.getByTestId('swap-buy-button')).toBeVisible(); console.log('[DEEPLINK] ✅ Local swap widget verified'); } else { // Production mode: verify the Uniswap link const swapLink = page.locator('a.swap-button'); await expect(swapLink).toBeVisible({ timeout: 10_000 }); const href = await swapLink.getAttribute('href'); expect(href).toBeTruthy(); // Parse and validate the URL structure const url = new URL(href!); expect(url.hostname).toBe('app.uniswap.org'); expect(url.pathname).toBe('/swap'); expect(url.searchParams.has('chain')).toBeTruthy(); expect(url.searchParams.has('outputCurrency')).toBeTruthy(); const chain = url.searchParams.get('chain'); const outputCurrency = url.searchParams.get('outputCurrency'); // Chain must be a valid Uniswap chain identifier expect(['base', 'base_sepolia', 'mainnet']).toContain(chain); // Token address must be a valid Ethereum address expect(outputCurrency).toMatch(/^0x[a-fA-F0-9]{40}$/); console.log(`[DEEPLINK] ✅ Uniswap deep link valid: chain=${chain}, token=${outputCurrency}`); } // Verify network info display const networkInfo = page.locator('.swap-info .info-value').first(); if (await networkInfo.isVisible({ timeout: 3_000 }).catch(() => false)) { const networkText = await networkInfo.textContent(); console.log(`[DEEPLINK] Network displayed: ${networkText}`); expect(networkText).toBeTruthy(); } } finally { await context.close(); } }); });