/** * Test B: Comprehensive Staker Journey (v2) * * Tests the full staking flow with three personas: * - Marcus (Anvil #3): "the snatcher" - executes snatch operations * - Sarah (Anvil #4): "the risk manager" - focuses on P&L and exit * - Priya (Anvil #5): "new staker" - fresh staking experience * * Prerequisites: Run setup-chain-state.ts to prepare initial positions */ import { expect, test } from '@playwright/test'; import { Wallet, ethers } from 'ethers'; import { createWalletContext } from '../../setup/wallet-provider'; import { getStackConfig, validateStackHealthy } from '../../setup/stack'; import { createPersonaFeedback, addFeedbackStep, writePersonaFeedback, resetChainState, connectWallet, type PersonaFeedback, } from './helpers'; import { mkdirSync } from 'fs'; import { join } from 'path'; import { execSync } from 'child_process'; const STACK_CONFIG = getStackConfig(); const STACK_RPC_URL = STACK_CONFIG.rpcUrl; const STAKE_PAGE_URL = `${STACK_CONFIG.webAppUrl}/app/stake`; // Anvil test account keys const MARCUS_KEY = '0x7c852118294e51e653712a81e05800f419141751be58f605c371e15141b007a6'; // Anvil #3 const SARAH_KEY = '0x47e179ec197488593b187f80a00eb0da91f1b9d0b13f8733639f19c30a34926a'; // Anvil #4 const PRIYA_KEY = '0x8b3a350cf5c34c9194ca85829a2df0ec3153be0318b5e2d3348e872092edffba'; // Anvil #5 test.describe('Test B: Staker Journey v2', () => { test.beforeAll(async () => { console.log('[SETUP] Validating stack health...'); await validateStackHealthy(STACK_CONFIG); console.log('[SETUP] Running chain state setup script...'); try { execSync('npx tsx tests/e2e/usertest/setup-chain-state.ts', { cwd: process.cwd(), stdio: 'inherit', }); } catch (error) { console.error('[SETUP] Chain state setup failed:', error); throw error; } console.log('[SETUP] Saving initial snapshot for persona resets...'); await resetChainState(STACK_RPC_URL); }); test.describe.serial('Marcus - "the snatcher"', () => { let feedback: PersonaFeedback; const accountKey = MARCUS_KEY; const accountAddr = new Wallet(accountKey).address; test.beforeAll(() => { feedback = createPersonaFeedback('marcus-v2', 'B', 'staker'); }); test('Marcus connects wallet and navigates to stake page', async ({ browser }) => { const context = await createWalletContext(browser, { privateKey: accountKey, rpcUrl: STACK_RPC_URL, }); const page = await context.newPage(); const observations: string[] = []; try { observations.push('Navigating to stake page...'); await page.goto(STAKE_PAGE_URL, { waitUntil: 'domcontentloaded' }); await page.waitForTimeout(3_000); // Connect wallet observations.push('Connecting wallet...'); await connectWallet(page); await page.waitForTimeout(2_000); const walletDisplay = page.getByText(/0x[a-fA-F0-9]{4}/i).first(); const isConnected = await walletDisplay.isVisible().catch(() => false); if (isConnected) { observations.push('✓ Wallet connected successfully'); } else { observations.push('✗ Wallet connection failed'); feedback.overall.friction.push('Wallet connection failed'); } // Screenshot const screenshotDir = join('test-results', 'usertest', 'marcus-v2'); mkdirSync(screenshotDir, { recursive: true }); const screenshotPath = join(screenshotDir, `01-wallet-connected-${Date.now()}.png`); await page.screenshot({ path: screenshotPath, fullPage: true }); addFeedbackStep(feedback, 'connect-wallet', observations, screenshotPath); } finally { await context.close(); } }); test('Marcus verifies his existing position is visible with P&L', async ({ browser }) => { const context = await createWalletContext(browser, { privateKey: accountKey, rpcUrl: STACK_RPC_URL, }); const page = await context.newPage(); const observations: string[] = []; try { await page.goto(STAKE_PAGE_URL, { waitUntil: 'domcontentloaded' }); await page.waitForTimeout(3_000); await connectWallet(page); await page.waitForTimeout(3_000); observations.push('Looking for my existing position created in setup...'); // Look for active positions section const activePositions = page.locator('.active-positions-wrapper, .f-collapse-active, [class*="position"]'); const hasPositions = await activePositions.isVisible({ timeout: 10_000 }).catch(() => false); if (hasPositions) { const positionCount = await page.locator('.f-collapse-active').count(); observations.push(`✓ Found ${positionCount} active position(s)`); // Check for P&L display const hasPnL = await page.locator('.pnl-metrics, .pnl-line1, text=/gross|tax|net/i').isVisible().catch(() => false); if (hasPnL) { observations.push('✓ P&L metrics visible (Gross/Tax/Net)'); } else { observations.push('⚠ P&L metrics not visible'); feedback.overall.friction.push('Position P&L not displayed'); } // Check for position details const hasDetails = await page.locator('text=/initial stake|tax rate|time held/i').isVisible().catch(() => false); if (hasDetails) { observations.push('✓ Position details displayed'); } else { observations.push('⚠ Position details incomplete'); } } else { observations.push('✗ No active positions found - setup may have failed'); feedback.overall.friction.push('Position created in setup not visible'); } // Screenshot const screenshotDir = join('test-results', 'usertest', 'marcus-v2'); const screenshotPath = join(screenshotDir, `02-existing-position-${Date.now()}.png`); await page.screenshot({ path: screenshotPath, fullPage: true }); addFeedbackStep(feedback, 'verify-existing-position', observations, screenshotPath); } finally { await context.close(); } }); test('Marcus finds Sarah\'s position and executes snatch', async ({ browser }) => { const context = await createWalletContext(browser, { privateKey: accountKey, rpcUrl: STACK_RPC_URL, }); const page = await context.newPage(); const observations: string[] = []; try { await page.goto(STAKE_PAGE_URL, { waitUntil: 'domcontentloaded' }); await page.waitForTimeout(3_000); await connectWallet(page); await page.waitForTimeout(3_000); observations.push('Looking for other positions with lower tax rates to snatch...'); // Check if we can see other positions (not just our own) const allPositions = await page.locator('.f-collapse-active, [class*="position-card"]').count(); observations.push(`Found ${allPositions} total positions visible`); // Fill stake form to snatch observations.push('Filling snatch form: amount + higher tax rate...'); const amountInput = page.getByLabel('Staking Amount').or(page.locator('input[type="number"]').first()); await amountInput.waitFor({ state: 'visible', timeout: 10_000 }); await amountInput.fill('200'); // Amount to snatch await page.waitForTimeout(500); // Select HIGHER tax rate than victim (Sarah has index 10, so use index 12+) const taxSelect = page.locator('select.tax-select').or(page.getByRole('combobox', { name: /tax/i }).first()); await taxSelect.selectOption({ index: 12 }); // Higher than Sarah's medium tax await page.waitForTimeout(1_000); // Check button text const stakeButton = page.getByRole('button', { name: /snatch and stake|stake/i }).first(); const buttonText = await stakeButton.textContent().catch(() => ''); if (buttonText?.toLowerCase().includes('snatch')) { observations.push('✓ Button shows "Snatch and Stake" - clear action'); // Check for snatch summary const summary = page.locator('.stake-summary, text=/snatch/i'); const hasSummary = await summary.isVisible().catch(() => false); if (hasSummary) { observations.push('✓ Snatch summary visible'); } // Screenshot before snatch const screenshotDir = join('test-results', 'usertest', 'marcus-v2'); const preSnatchPath = join(screenshotDir, `03-pre-snatch-${Date.now()}.png`); await page.screenshot({ path: preSnatchPath, fullPage: true }); // Execute snatch observations.push('Executing snatch transaction...'); await stakeButton.click(); await page.waitForTimeout(2_000); // Wait for transaction completion try { await page.getByRole('button', { name: /^(snatch and stake|stake)$/i }).waitFor({ state: 'visible', timeout: 30_000 }); observations.push('✓ Snatch transaction completed'); } catch (error) { observations.push('⚠ Snatch transaction may be pending'); } await page.waitForTimeout(3_000); // Verify snatched position appears const newPositionCount = await page.locator('.f-collapse-active').count(); observations.push(`Now have ${newPositionCount} active position(s)`); } else { observations.push('Button shows "Stake" - may not be snatching or no targets available'); } // Screenshot after snatch const screenshotDir = join('test-results', 'usertest', 'marcus-v2'); const postSnatchPath = join(screenshotDir, `04-post-snatch-${Date.now()}.png`); await page.screenshot({ path: postSnatchPath, fullPage: true }); addFeedbackStep(feedback, 'execute-snatch', observations, postSnatchPath); } finally { await context.close(); writePersonaFeedback(feedback); } }); }); test.describe.serial('Sarah - "the risk manager"', () => { let feedback: PersonaFeedback; const accountKey = SARAH_KEY; const accountAddr = new Wallet(accountKey).address; test.beforeAll(() => { feedback = createPersonaFeedback('sarah-v2', 'B', 'staker'); }); test('Sarah connects and views her position', async ({ browser }) => { const context = await createWalletContext(browser, { privateKey: accountKey, rpcUrl: STACK_RPC_URL, }); const page = await context.newPage(); const observations: string[] = []; try { observations.push('Sarah connecting to view her staked position...'); await page.goto(STAKE_PAGE_URL, { waitUntil: 'domcontentloaded' }); await page.waitForTimeout(3_000); await connectWallet(page); await page.waitForTimeout(3_000); // Look for position const hasPosition = await page.locator('.f-collapse-active, [class*="position"]').isVisible().catch(() => false); if (hasPosition) { observations.push('✓ Position visible'); } else { observations.push('✗ Position not found - may have been snatched'); } // Screenshot const screenshotDir = join('test-results', 'usertest', 'sarah-v2'); mkdirSync(screenshotDir, { recursive: true }); const screenshotPath = join(screenshotDir, `01-view-position-${Date.now()}.png`); await page.screenshot({ path: screenshotPath, fullPage: true }); addFeedbackStep(feedback, 'view-position', observations, screenshotPath); } finally { await context.close(); } }); test('Sarah checks P&L display (gross return, tax cost, net return)', async ({ browser }) => { const context = await createWalletContext(browser, { privateKey: accountKey, rpcUrl: STACK_RPC_URL, }); const page = await context.newPage(); const observations: string[] = []; try { await page.goto(STAKE_PAGE_URL, { waitUntil: 'domcontentloaded' }); await page.waitForTimeout(3_000); await connectWallet(page); await page.waitForTimeout(3_000); observations.push('Analyzing P&L metrics for risk assessment...'); // Check for P&L breakdown const pnlLine = page.locator('.pnl-line1, text=/gross.*tax.*net/i'); const hasPnL = await pnlLine.isVisible().catch(() => false); if (hasPnL) { const pnlText = await pnlLine.textContent().catch(() => ''); observations.push(`✓ P&L display found: ${pnlText}`); // Check for positive/negative indicators const isPositive = await page.locator('.pnl-positive').isVisible().catch(() => false); const isNegative = await page.locator('.pnl-negative').isVisible().catch(() => false); if (isPositive) { observations.push('✓ Net return is positive (green)'); } else if (isNegative) { observations.push('⚠ Net return is negative (red)'); } } else { observations.push('✗ P&L metrics not visible'); feedback.overall.friction.push('P&L display missing'); } // Check for time held const timeHeld = page.locator('.pnl-line2, text=/held.*d.*h/i'); const hasTimeHeld = await timeHeld.isVisible().catch(() => false); if (hasTimeHeld) { const timeText = await timeHeld.textContent().catch(() => ''); observations.push(`✓ Time held displayed: ${timeText}`); } else { observations.push('⚠ Time held not visible'); } // Screenshot const screenshotDir = join('test-results', 'usertest', 'sarah-v2'); const screenshotPath = join(screenshotDir, `02-pnl-analysis-${Date.now()}.png`); await page.screenshot({ path: screenshotPath, fullPage: true }); addFeedbackStep(feedback, 'check-pnl', observations, screenshotPath); } finally { await context.close(); } }); test('Sarah executes exitPosition to recover her KRK', async ({ browser }) => { const context = await createWalletContext(browser, { privateKey: accountKey, rpcUrl: STACK_RPC_URL, }); const page = await context.newPage(); const observations: string[] = []; try { await page.goto(STAKE_PAGE_URL, { waitUntil: 'domcontentloaded' }); await page.waitForTimeout(3_000); await connectWallet(page); await page.waitForTimeout(3_000); observations.push('Exiting position to recover KRK...'); // Find position and expand it const position = page.locator('.f-collapse-active').first(); const hasPosition = await position.isVisible().catch(() => false); if (!hasPosition) { observations.push('✗ No position to exit - may have been snatched already'); feedback.overall.friction.push('Position disappeared before exit'); const screenshotDir = join('test-results', 'usertest', 'sarah-v2'); const screenshotPath = join(screenshotDir, `03-no-position-${Date.now()}.png`); await page.screenshot({ path: screenshotPath, fullPage: true }); addFeedbackStep(feedback, 'exit-position', observations, screenshotPath); await context.close(); return; } // Expand position to see actions await position.click(); await page.waitForTimeout(1_000); // Look for Unstake/Exit button const exitButton = position.getByRole('button', { name: /unstake|exit/i }); const hasExitButton = await exitButton.isVisible().catch(() => false); if (hasExitButton) { observations.push('✓ Exit button found'); // Screenshot before exit const screenshotDir = join('test-results', 'usertest', 'sarah-v2'); const preExitPath = join(screenshotDir, `03-pre-exit-${Date.now()}.png`); await page.screenshot({ path: preExitPath, fullPage: true }); // Click exit await exitButton.click(); await page.waitForTimeout(2_000); // Wait for transaction try { await page.waitForTimeout(5_000); // Give time for tx confirmation observations.push('✓ Exit transaction submitted'); } catch (error) { observations.push('⚠ Exit transaction may be pending'); } // Verify position is gone await page.waitForTimeout(3_000); const stillVisible = await position.isVisible().catch(() => false); if (!stillVisible) { observations.push('✓ Position removed from Active Positions'); } else { observations.push('⚠ Position still visible after exit'); } // Screenshot after exit const postExitPath = join(screenshotDir, `04-post-exit-${Date.now()}.png`); await page.screenshot({ path: postExitPath, fullPage: true }); } else { observations.push('✗ Exit button not found'); feedback.overall.friction.push('Exit mechanism not accessible'); } addFeedbackStep(feedback, 'exit-position', observations); } finally { await context.close(); writePersonaFeedback(feedback); } }); }); test.describe.serial('Priya - "new staker"', () => { let feedback: PersonaFeedback; const accountKey = PRIYA_KEY; const accountAddr = new Wallet(accountKey).address; test.beforeAll(() => { feedback = createPersonaFeedback('priya-v2', 'B', 'staker'); }); test('Priya connects wallet (fresh staker, no positions)', async ({ browser }) => { const context = await createWalletContext(browser, { privateKey: accountKey, rpcUrl: STACK_RPC_URL, }); const page = await context.newPage(); const observations: string[] = []; try { observations.push('Priya (fresh staker) connecting wallet...'); await page.goto(STAKE_PAGE_URL, { waitUntil: 'domcontentloaded' }); await page.waitForTimeout(3_000); await connectWallet(page); await page.waitForTimeout(3_000); // Verify no existing positions const hasPositions = await page.locator('.f-collapse-active').isVisible({ timeout: 5_000 }).catch(() => false); if (!hasPositions) { observations.push('✓ No existing positions (fresh staker)'); } else { observations.push('⚠ Found existing positions - test may be contaminated'); } // Screenshot const screenshotDir = join('test-results', 'usertest', 'priya-v2'); mkdirSync(screenshotDir, { recursive: true }); const screenshotPath = join(screenshotDir, `01-fresh-state-${Date.now()}.png`); await page.screenshot({ path: screenshotPath, fullPage: true }); addFeedbackStep(feedback, 'connect-wallet', observations, screenshotPath); } finally { await context.close(); } }); test('Priya fills staking amount using selectors from reference', async ({ browser }) => { const context = await createWalletContext(browser, { privateKey: accountKey, rpcUrl: STACK_RPC_URL, }); const page = await context.newPage(); const observations: string[] = []; try { await page.goto(STAKE_PAGE_URL, { waitUntil: 'domcontentloaded' }); await page.waitForTimeout(3_000); await connectWallet(page); await page.waitForTimeout(3_000); observations.push('Filling staking form as a new user...'); // Use selector from reference doc: page.getByLabel('Staking Amount') const amountInput = page.getByLabel('Staking Amount'); const hasInput = await amountInput.isVisible({ timeout: 10_000 }).catch(() => false); if (hasInput) { observations.push('✓ Staking Amount input found'); await amountInput.fill('100'); await page.waitForTimeout(500); observations.push('✓ Filled amount: 100 KRK'); } else { observations.push('✗ Staking Amount input not found'); feedback.overall.friction.push('Staking amount input not accessible'); } // Screenshot const screenshotDir = join('test-results', 'usertest', 'priya-v2'); const screenshotPath = join(screenshotDir, `02-amount-filled-${Date.now()}.png`); await page.screenshot({ path: screenshotPath, fullPage: true }); addFeedbackStep(feedback, 'fill-amount', observations, screenshotPath); } finally { await context.close(); } }); test('Priya selects tax rate via dropdown', async ({ browser }) => { const context = await createWalletContext(browser, { privateKey: accountKey, rpcUrl: STACK_RPC_URL, }); const page = await context.newPage(); const observations: string[] = []; try { await page.goto(STAKE_PAGE_URL, { waitUntil: 'domcontentloaded' }); await page.waitForTimeout(3_000); await connectWallet(page); await page.waitForTimeout(3_000); // Fill amount first const amountInput = page.getByLabel('Staking Amount'); await amountInput.fill('100'); await page.waitForTimeout(500); observations.push('Selecting tax rate...'); // Use selector from reference: page.locator('select.tax-select') const taxSelect = page.locator('select.tax-select'); const hasTaxSelect = await taxSelect.isVisible({ timeout: 10_000 }).catch(() => false); if (hasTaxSelect) { observations.push('✓ Tax rate selector found'); // Select a mid-range tax rate (index 5) await taxSelect.selectOption({ index: 5 }); await page.waitForTimeout(500); const selectedValue = await taxSelect.inputValue(); observations.push(`✓ Selected tax rate index: ${selectedValue}`); } else { observations.push('✗ Tax rate selector not found'); feedback.overall.friction.push('Tax rate selector not accessible'); } // Screenshot const screenshotDir = join('test-results', 'usertest', 'priya-v2'); const screenshotPath = join(screenshotDir, `03-tax-selected-${Date.now()}.png`); await page.screenshot({ path: screenshotPath, fullPage: true }); addFeedbackStep(feedback, 'select-tax-rate', observations, screenshotPath); } finally { await context.close(); } }); test('Priya clicks Snatch and Stake button and handles permit signing', async ({ browser }) => { const context = await createWalletContext(browser, { privateKey: accountKey, rpcUrl: STACK_RPC_URL, }); const page = await context.newPage(); const observations: string[] = []; try { await page.goto(STAKE_PAGE_URL, { waitUntil: 'domcontentloaded' }); await page.waitForTimeout(3_000); await connectWallet(page); await page.waitForTimeout(3_000); observations.push('Completing stake form and executing transaction...'); // Fill form const amountInput = page.getByLabel('Staking Amount'); await amountInput.fill('100'); await page.waitForTimeout(500); const taxSelect = page.locator('select.tax-select'); await taxSelect.selectOption({ index: 5 }); await page.waitForTimeout(1_000); // Find stake button using reference selector const stakeButton = page.getByRole('button', { name: /snatch and stake/i }); const hasButton = await stakeButton.isVisible({ timeout: 10_000 }).catch(() => false); if (hasButton) { const buttonText = await stakeButton.textContent().catch(() => ''); observations.push(`✓ Stake button found: "${buttonText}"`); // Check if enabled const isEnabled = await stakeButton.isEnabled().catch(() => false); if (!isEnabled) { observations.push('⚠ Button is disabled - checking for errors...'); // Check for error messages const errorMessages = await page.locator('text=/insufficient|too low|invalid/i').allTextContents(); if (errorMessages.length > 0) { observations.push(`✗ Errors: ${errorMessages.join(', ')}`); feedback.overall.friction.push('Stake button disabled with errors'); } } else { observations.push('✓ Button is enabled'); // Screenshot before stake const screenshotDir = join('test-results', 'usertest', 'priya-v2'); const preStakePath = join(screenshotDir, `04-pre-stake-${Date.now()}.png`); await page.screenshot({ path: preStakePath, fullPage: true }); // Click stake button observations.push('Clicking stake button...'); await stakeButton.click(); await page.waitForTimeout(2_000); // The wallet provider auto-signs, but check for transaction state observations.push('✓ Permit signing handled by wallet provider (EIP-2612)'); // Wait for transaction completion try { await page.waitForTimeout(5_000); observations.push('✓ Transaction submitted'); } catch (error) { observations.push('⚠ Transaction may be pending'); } await page.waitForTimeout(3_000); // Screenshot after stake const postStakePath = join(screenshotDir, `05-post-stake-${Date.now()}.png`); await page.screenshot({ path: postStakePath, fullPage: true }); } } else { observations.push('✗ Stake button not found'); feedback.overall.friction.push('Stake button not accessible'); } addFeedbackStep(feedback, 'execute-stake', observations); } finally { await context.close(); } }); test('Priya verifies position appears in Active Positions', async ({ browser }) => { const context = await createWalletContext(browser, { privateKey: accountKey, rpcUrl: STACK_RPC_URL, }); const page = await context.newPage(); const observations: string[] = []; try { await page.goto(STAKE_PAGE_URL, { waitUntil: 'domcontentloaded' }); await page.waitForTimeout(3_000); await connectWallet(page); await page.waitForTimeout(3_000); observations.push('Checking for new position in Active Positions...'); // Look for active positions wrapper (from reference) const activePositionsWrapper = page.locator('.active-positions-wrapper'); const hasWrapper = await activePositionsWrapper.isVisible({ timeout: 10_000 }).catch(() => false); if (hasWrapper) { observations.push('✓ Active Positions section found'); // Count positions const positionCount = await page.locator('.f-collapse-active').count(); if (positionCount > 0) { observations.push(`✓ Found ${positionCount} active position(s)`); feedback.overall.wouldStake = true; feedback.overall.wouldReturn = true; } else { observations.push('⚠ No positions visible - stake may have failed'); feedback.overall.wouldStake = false; } } else { observations.push('✗ Active Positions section not found'); feedback.overall.friction.push('Active Positions not visible after stake'); } // Final screenshot const screenshotDir = join('test-results', 'usertest', 'priya-v2'); const screenshotPath = join(screenshotDir, `06-final-state-${Date.now()}.png`); await page.screenshot({ path: screenshotPath, fullPage: true }); addFeedbackStep(feedback, 'verify-position', observations, screenshotPath); // Priya's verdict observations.push(`Priya verdict: ${feedback.overall.wouldStake ? 'Successful first stake' : 'Stake failed or unclear'}`); } finally { await context.close(); writePersonaFeedback(feedback); } }); }); });