import type { Page, BrowserContext } from '@playwright/test'; import { writeFileSync, mkdirSync, readFileSync, existsSync } from 'fs'; import { join } from 'path'; // Global snapshot state for chain resets - persisted to disk const SNAPSHOT_FILE = join(process.cwd(), 'tmp', '.chain-snapshot-id'); let initialSnapshotId: string | null = null; let currentSnapshotId: string | null = null; // Load snapshot ID from disk if it exists function loadSnapshotId(): string | null { try { if (existsSync(SNAPSHOT_FILE)) { return readFileSync(SNAPSHOT_FILE, 'utf-8').trim(); } } catch (e) { console.warn(`[CHAIN] Could not read snapshot file: ${e}`); } return null; } // Save snapshot ID to disk function saveSnapshotId(id: string): void { try { mkdirSync(join(process.cwd(), 'tmp'), { recursive: true }); writeFileSync(SNAPSHOT_FILE, id, 'utf-8'); } catch (e) { console.warn(`[CHAIN] Could not write snapshot file: ${e}`); } } /** * Reset chain state using evm_snapshot/evm_revert * On first call: takes the initial snapshot (clean state) and saves to disk * On subsequent calls: reverts to initial snapshot, then takes a new snapshot * This preserves deployed contracts but resets balances and pool state to initial conditions * * Snapshot ID is persisted to disk so it survives module reloads between tests */ export async function resetChainState(rpcUrl: string): Promise { // Try to load from disk first (in case module was reloaded) if (!initialSnapshotId) { initialSnapshotId = loadSnapshotId(); if (initialSnapshotId) { console.log(`[CHAIN] Loaded initial snapshot from disk: ${initialSnapshotId}`); } } if (initialSnapshotId) { // Revert to the initial snapshot console.log(`[CHAIN] Reverting to initial snapshot ${initialSnapshotId}...`); const revertRes = await fetch(rpcUrl, { method: 'POST', headers: { 'content-type': 'application/json' }, body: JSON.stringify({ jsonrpc: '2.0', method: 'evm_revert', params: [initialSnapshotId], id: 1 }) }); const revertData = await revertRes.json(); if (!revertData.result) { // Revert failed - clear snapshot file and take a fresh one console.error(`[CHAIN] Revert FAILED: ${JSON.stringify(revertData)}`); console.log(`[CHAIN] Clearing snapshot file and taking fresh snapshot...`); initialSnapshotId = null; // Fall through to take fresh snapshot below } else { console.log(`[CHAIN] Reverted successfully to initial state`); // After successful revert, take a new snapshot (anvil consumes the old one) console.log('[CHAIN] Taking new snapshot after successful revert...'); const newSnapshotRes = await fetch(rpcUrl, { method: 'POST', headers: { 'content-type': 'application/json' }, body: JSON.stringify({ jsonrpc: '2.0', method: 'evm_snapshot', params: [], id: 1 }) }); const newSnapshotData = await newSnapshotRes.json(); currentSnapshotId = newSnapshotData.result; // CRITICAL: Update initialSnapshotId because anvil consumed it during revert initialSnapshotId = currentSnapshotId; saveSnapshotId(initialSnapshotId); console.log(`[CHAIN] New initial snapshot taken (replaces consumed one): ${initialSnapshotId}`); return; } } // First call OR revert failed: take initial snapshot of CURRENT state console.log('[CHAIN] Taking FIRST initial snapshot...'); const snapshotRes = await fetch(rpcUrl, { method: 'POST', headers: { 'content-type': 'application/json' }, body: JSON.stringify({ jsonrpc: '2.0', method: 'evm_snapshot', params: [], id: 1 }) }); const snapshotData = await snapshotRes.json(); initialSnapshotId = snapshotData.result; currentSnapshotId = initialSnapshotId; saveSnapshotId(initialSnapshotId); console.log(`[CHAIN] Initial snapshot taken and saved to disk: ${initialSnapshotId}`); } export interface TestReport { personaName: string; testDate: string; pagesVisited: Array<{ page: string; url: string; timeSpent: number; // milliseconds timestamp: string; }>; actionsAttempted: Array<{ action: string; success: boolean; error?: string; timestamp: string; }>; screenshots: string[]; uiObservations: string[]; copyFeedback: string[]; tokenomicsQuestions: string[]; overallSentiment: string; } /** * Connect wallet using the injected test provider */ export async function connectWallet(page: Page): Promise { console.log('[HELPER] Connecting wallet...'); // Wait for Vue app to mount (increased timeout for post-chain-reset scenarios) const navbarTitle = page.locator('.navbar-title').first(); await navbarTitle.waitFor({ state: 'visible', timeout: 60_000 }); // Trigger resize event for mobile detection; connectButton.isVisible below waits for layout await page.evaluate(() => { window.dispatchEvent(new Event('resize')); }); // Wait for wagmi to settle — the connect button or connected display must appear. // After the wallet-provider fix, eth_accounts returns [] when not connected, // so wagmi should land on 'disconnected' status and render the connect button. const connectButton = page.locator('.connect-button--disconnected').first(); const connectedButton = page.locator('.connect-button--connected').first(); // Wait for either the disconnect button (normal case) or connected button (auto-reconnect) const desktopButton = page.locator('.connect-button--disconnected, .connect-button--connected').first(); if (await desktopButton.isVisible({ timeout: 10_000 })) { if (await connectedButton.isVisible({ timeout: 500 }).catch(() => false)) { // Wallet already connected (e.g. wagmi reconnected from storage) — skip connect flow console.log('[HELPER] Wallet already connected (auto-reconnect)'); } else { // Desktop connect button found — click to open connector panel console.log('[HELPER] Found desktop Connect button'); await connectButton.click(); // Wait for the connector panel to open — .connectors-element appearing is the observable event const injectedConnector = page.locator('.connectors-element').first(); await injectedConnector.waitFor({ state: 'visible', timeout: 10_000 }); console.log('[HELPER] Clicking wallet connector...'); await injectedConnector.click(); } } else { // Try mobile fallback const mobileLoginIcon = page.locator('.navbar-end svg').first(); if (await mobileLoginIcon.isVisible({ timeout: 2_000 })) { console.log('[HELPER] Using mobile login icon'); await mobileLoginIcon.click(); const injectedConnector = page.locator('.connectors-element').first(); await injectedConnector.waitFor({ state: 'visible', timeout: 10_000 }); await injectedConnector.click(); } } // Verify wallet is connected const walletDisplay = page.getByText(/0x[a-fA-F0-9]{4}/i).first(); await walletDisplay.waitFor({ state: 'visible', timeout: 15_000 }); console.log('[HELPER] Wallet connected successfully'); } /** * Mint ETH on the local Anvil fork (via RPC, not UI) * This is a direct RPC call to anvil_setBalance */ export async function mintEth( page: Page, rpcUrl: string, recipientAddress: string, amount: string = '10' ): Promise { console.log(`[HELPER] Minting ${amount} ETH to ${recipientAddress} via RPC...`); const amountWei = BigInt(parseFloat(amount) * 1e18).toString(16); const paddedAmount = '0x' + amountWei.padStart(64, '0'); const response = await fetch(rpcUrl, { method: 'POST', headers: { 'content-type': 'application/json' }, body: JSON.stringify({ jsonrpc: '2.0', method: 'anvil_setBalance', params: [recipientAddress, paddedAmount], id: 1 }) }); const result = await response.json(); if (result.error) { throw new Error(`Failed to mint ETH: ${result.error.message}`); } console.log(`[HELPER] ETH minted successfully via RPC`); } // Helper: send RPC call and return result async function sendRpc(rpcUrl: string, method: string, params: unknown[]): Promise { const resp = await fetch(rpcUrl, { method: 'POST', headers: { 'content-type': 'application/json' }, body: JSON.stringify({ jsonrpc: '2.0', id: Date.now(), method, params }) }); const data = await resp.json(); if (data.error) throw new Error(`RPC ${method} failed: ${data.error.message}`); return data.result; } // Helper: wait for transaction receipt async function waitForReceipt(rpcUrl: string, txHash: string, timeoutMs = 15000): Promise { const start = Date.now(); while (Date.now() - start < timeoutMs) { const resp = await fetch(rpcUrl, { method: 'POST', headers: { 'content-type': 'application/json' }, body: JSON.stringify({ jsonrpc: '2.0', id: 1, method: 'eth_getTransactionReceipt', params: [txHash] }) }); const data = await resp.json(); if (data.result) return data.result; // eslint-disable-next-line no-restricted-syntax -- Polling with timeout: no event source for transaction receipt over HTTP RPC (eth_subscribe not available). See AGENTS.md #Engineering Principles. await new Promise(r => setTimeout(r, 500)); } throw new Error(`Transaction ${txHash} not mined within ${timeoutMs}ms`); } /** * Fund a wallet with KRK tokens by transferring from the deployer (Anvil #0). * On the local fork, the deployer holds the initial KRK supply. * The ethAmount parameter is kept for API compatibility but controls KRK amount * (1 ETH ≈ 1000 KRK at the ~0.01 initialization price). */ export async function buyKrk( page: Page, ethAmount: string, rpcUrl: string = 'http://localhost:8545', privateKey?: string ): Promise { const deployments = JSON.parse(readFileSync(join(process.cwd(), 'onchain', 'deployments-local.json'), 'utf-8')); const krkAddress = deployments.contracts.Kraiken; // Determine recipient address let walletAddr: string; if (privateKey) { const { Wallet } = await import('ethers'); walletAddr = new Wallet(privateKey).address; } else { walletAddr = '0xf39Fd6e51aad88F6F4ce6aB8827279cffFb92266'; // default Anvil #0 } // Transfer KRK from deployer (Anvil #0) to recipient // Give 100 KRK per "ETH" parameter (deployer has ~2K KRK after bootstrap) const krkAmount = Math.min(parseFloat(ethAmount) * 100, 500); const { ethers } = await import('ethers'); const provider = new ethers.JsonRpcProvider(rpcUrl); const DEPLOYER_KEY = '0xac0974bec39a17e36ba4a6b4d238ff944bacb478cbed5efcae784d7bf4f2ff80'; const deployer = new ethers.Wallet(DEPLOYER_KEY, provider); const krk = new ethers.Contract(krkAddress, [ 'function transfer(address,uint256) returns (bool)', 'function balanceOf(address) view returns (uint256)' ], deployer); const amount = ethers.parseEther(krkAmount.toString()); console.log(`[HELPER] Transferring ${krkAmount} KRK to ${walletAddr}...`); const tx = await krk.transfer(walletAddr, amount); await tx.wait(); const balance = await krk.balanceOf(walletAddr); console.log(`[HELPER] KRK balance: ${ethers.formatEther(balance)} KRK`); } /** * Take an annotated screenshot with a description */ export async function takeScreenshot( page: Page, personaName: string, moment: string, report: TestReport ): Promise { const timestamp = new Date().toISOString().replace(/[:.]/g, '-'); const filename = `${personaName.toLowerCase().replace(/\s+/g, '-')}-${moment.toLowerCase().replace(/\s+/g, '-')}-${timestamp}.png`; const dirPath = join('test-results', 'usertest', personaName.toLowerCase().replace(/\s+/g, '-')); try { mkdirSync(dirPath, { recursive: true }); } catch (e) { // Directory may already exist } const filepath = join(dirPath, filename); await page.screenshot({ path: filepath, fullPage: true }); report.screenshots.push(filepath); console.log(`[SCREENSHOT] ${moment}: ${filepath}`); } /** * Log a persona observation (what they think/feel) */ export function logObservation(personaName: string, observation: string, report: TestReport): void { const message = `[${personaName}] ${observation}`; console.log(message); report.uiObservations.push(observation); } /** * Log copy/messaging feedback */ export function logCopyFeedback(personaName: string, feedback: string, report: TestReport): void { const message = `[${personaName} - COPY] ${feedback}`; console.log(message); report.copyFeedback.push(feedback); } /** * Log a tokenomics question the persona would have */ export function logTokenomicsQuestion(personaName: string, question: string, report: TestReport): void { const message = `[${personaName} - TOKENOMICS] ${question}`; console.log(message); report.tokenomicsQuestions.push(question); } /** * Record a page visit */ export function recordPageVisit( pageName: string, url: string, startTime: number, report: TestReport ): void { const timeSpent = Date.now() - startTime; report.pagesVisited.push({ page: pageName, url, timeSpent, timestamp: new Date().toISOString(), }); } /** * Record an action attempt */ export function recordAction( action: string, success: boolean, error: string | undefined, report: TestReport ): void { report.actionsAttempted.push({ action, success, error, timestamp: new Date().toISOString(), }); } /** * Write the final report to JSON */ export function writeReport(personaName: string, report: TestReport): void { const dirPath = join(process.cwd(), 'tmp', 'usertest-results'); try { mkdirSync(dirPath, { recursive: true }); } catch (e) { // Directory may already exist } const filename = `${personaName.toLowerCase().replace(/\s+/g, '-')}.json`; const filepath = join(dirPath, filename); writeFileSync(filepath, JSON.stringify(report, null, 2), 'utf-8'); console.log(`[REPORT] Written to ${filepath}`); } /** * Create a new test report */ export function createReport(personaName: string): TestReport { return { personaName, testDate: new Date().toISOString(), pagesVisited: [], actionsAttempted: [], screenshots: [], uiObservations: [], copyFeedback: [], tokenomicsQuestions: [], overallSentiment: '', }; } /** * New feedback structure for redesigned tests */ export interface PersonaFeedback { persona: string; test: 'A' | 'B'; timestamp: string; journey: 'passive-holder' | 'staker'; steps: Array<{ step: string; screenshot?: string; feedback: string[]; }>; overall: { wouldBuy?: boolean; wouldReturn?: boolean; wouldStake?: boolean; friction: string[]; }; } /** * Create new feedback structure */ export function createPersonaFeedback( persona: string, test: 'A' | 'B', journey: 'passive-holder' | 'staker' ): PersonaFeedback { return { persona, test, timestamp: new Date().toISOString(), journey, steps: [], overall: { friction: [] } }; } /** * Add a step to persona feedback */ export function addFeedbackStep( feedback: PersonaFeedback, step: string, observations: string[], screenshot?: string ): void { feedback.steps.push({ step, screenshot, feedback: observations }); } /** * Write persona feedback to JSON */ export function writePersonaFeedback(feedback: PersonaFeedback): void { const dirPath = join(process.cwd(), 'tmp', 'usertest-results'); try { mkdirSync(dirPath, { recursive: true }); } catch (e) { // Directory may already exist } const filename = `${feedback.persona.toLowerCase()}-test-${feedback.test.toLowerCase()}.json`; const filepath = join(dirPath, filename); writeFileSync(filepath, JSON.stringify(feedback, null, 2), 'utf-8'); console.log(`[FEEDBACK] Written to ${filepath}`); } /** * Navigate to stake page and attempt to stake */ export async function attemptStake( page: Page, amount: string, taxRateIndex: string, personaName: string, report: TestReport ): Promise { console.log(`[${personaName}] Attempting to stake ${amount} KRK at tax rate ${taxRateIndex}%...`); const baseUrl = page.url().split('#')[0]; await page.goto(`${baseUrl}stake`); try { // Wait for stake form to fully load const tokenAmountSlider = page.getByRole('slider', { name: 'Token Amount' }); await tokenAmountSlider.waitFor({ state: 'visible', timeout: 15_000 }); // Wait for KRK balance to load in UI (critical — without this, button shows "Insufficient Balance") console.log(`[${personaName}] Waiting for KRK balance to load in UI...`); try { await page.waitForFunction(() => { const balEl = document.querySelector('.balance'); if (!balEl) return false; const text = balEl.textContent || ''; const match = text.match(/([\d,.]+)/); return match && parseFloat(match[1].replace(/,/g, '')) > 0; }, { timeout: 150_000 }); const balText = await page.locator('.balance').first().textContent(); console.log(`[${personaName}] Balance loaded: ${balText}`); } catch (e) { console.log(`[${personaName}] WARNING: Balance did not load within 90s — staking may fail`); } // Fill amount const stakeAmountInput = page.getByLabel('Staking Amount'); await stakeAmountInput.waitFor({ state: 'visible', timeout: 10_000 }); await stakeAmountInput.fill(amount); // Select tax rate const taxSelect = page.getByRole('combobox', { name: 'Tax' }); await taxSelect.selectOption({ value: taxRateIndex }); // Take screenshot before attempting to click const screenshotDir = join('test-results', 'usertest', personaName.toLowerCase().replace(/\s+/g, '-')); mkdirSync(screenshotDir, { recursive: true }); const timestamp = new Date().toISOString().replace(/[:.]/g, '-'); const screenshotPath = join(screenshotDir, `stake-form-filled-${timestamp}.png`); await page.screenshot({ path: screenshotPath, fullPage: true }); report.screenshots.push(screenshotPath); console.log(`[${personaName}] Screenshot: ${screenshotPath}`); // Find ALL buttons in the stake form to see actual state const allButtons = await page.getByRole('main').getByRole('button').all(); const buttonTexts = await Promise.all( allButtons.map(async (btn) => { try { return await btn.textContent(); } catch { return null; } }) ); console.log(`[${personaName}] Available buttons: ${buttonTexts.filter(Boolean).join(', ')}`); // Check for error state buttons const buttonText = buttonTexts.join(' '); if (buttonText.includes('Insufficient Balance')) { const errorMsg = 'Cannot stake: Insufficient KRK balance. Buy more KRK first.'; console.log(`[${personaName}] ${errorMsg}`); recordAction(`Stake ${amount} KRK at ${taxRateIndex}% tax`, false, errorMsg, report); throw new Error(errorMsg); } if (buttonText.includes('Stake Amount Too Low')) { const errorMsg = 'Cannot stake: Amount is below minimum stake requirement.'; console.log(`[${personaName}] ${errorMsg}`); recordAction(`Stake ${amount} KRK at ${taxRateIndex}% tax`, false, errorMsg, report); throw new Error(errorMsg); } if (buttonText.includes('Tax Rate Too Low')) { const errorMsg = 'Cannot stake: No open positions at this tax rate. Increase tax rate.'; console.log(`[${personaName}] ${errorMsg}`); recordAction(`Stake ${amount} KRK at ${taxRateIndex}% tax`, false, errorMsg, report); throw new Error(errorMsg); } // Wait for stake button with longer timeout const stakeButton = page.getByRole('main').getByRole('button', { name: /^(Stake|Snatch and Stake)$/i }); await stakeButton.waitFor({ state: 'visible', timeout: 15_000 }); const finalButtonText = await stakeButton.textContent(); console.log(`[${personaName}] Clicking button: "${finalButtonText}"`); await stakeButton.click(); // Wait for transaction try { await page.getByRole('button', { name: /Sign Transaction|Waiting/i }).waitFor({ state: 'visible', timeout: 5_000 }); await page.getByRole('button', { name: /^(Stake|Snatch and Stake)$/i }).waitFor({ state: 'visible', timeout: 60_000 }); } catch (e) { // May complete instantly } recordAction(`Stake ${amount} KRK at ${taxRateIndex}% tax`, true, undefined, report); console.log(`[${personaName}] Stake successful`); } catch (error: any) { recordAction(`Stake ${amount} KRK at ${taxRateIndex}% tax`, false, error.message, report); console.log(`[${personaName}] Stake failed: ${error.message}`); throw error; } }