harb/tests/e2e/usertest/test-b-staker-v2.spec.ts

751 lines
28 KiB
TypeScript
Raw Normal View History

2026-02-18 00:19:05 +01:00
/**
* 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);
}
});
});
});