harb/tests/e2e/07-conversion-funnel.spec.ts
johba e562a51d47 fix: use waitFor instead of isVisible for wallet connect in deep link test
The deep link test's wallet connection was silently failing because
isVisible() returns immediately without waiting for the element to
appear. wagmi needs time to settle into 'disconnected' state after
provider injection. Now uses waitFor() which properly auto-retries,
plus adds a 2s delay matching the pattern used in test 01.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-25 11:41:35 +00:00

368 lines
17 KiB
TypeScript

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<Array<{ name: string; data: Record<string, unknown> }>> {
return page.evaluate(() => (window as unknown as { __analytics_events: Array<{ name: string; data: Record<string, unknown> }> }).__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<string, unknown>) => 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.
const navbarTitle = page.locator('.navbar-title').first();
await navbarTitle.waitFor({ state: 'visible', timeout: 30_000 });
await page.evaluate(() => window.dispatchEvent(new Event('resize')));
// eslint-disable-next-line no-restricted-syntax -- waitForTimeout: wagmi needs time to settle into disconnected/connected state after provider injection; no observable DOM event signals readiness. See AGENTS.md #Engineering Principles.
await page.waitForTimeout(2_000);
// Check if wallet already connected (wagmi may auto-reconnect from storage)
const alreadyConnected = page.locator('.connect-button--connected').first();
if (await alreadyConnected.isVisible({ timeout: 1_000 }).catch(() => false)) {
console.log('[DEEPLINK] Wallet already connected (auto-reconnect)');
} else {
// Wait for wagmi to settle into disconnected state and render the connect button
const connectBtn = page.locator('.connect-button--disconnected').first();
await connectBtn.waitFor({ state: 'visible', timeout: 15_000 });
console.log('[DEEPLINK] Found desktop Connect button, clicking...');
await connectBtn.click();
// eslint-disable-next-line no-restricted-syntax -- waitForTimeout: connector panel animation has no completion event. See AGENTS.md #Engineering Principles.
await page.waitForTimeout(1_000);
const connector = page.locator('.connectors-element').first();
await connector.waitFor({ state: 'visible', timeout: 10_000 });
console.log('[DEEPLINK] Clicking wallet connector...');
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();
}
});
});