harb/tests/e2e/usertest/helpers.ts

623 lines
20 KiB
TypeScript
Raw Normal View History

2026-02-18 00:19:05 +01:00
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<void> {
// 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<void> {
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
await page.evaluate(() => {
window.dispatchEvent(new Event('resize'));
});
await page.waitForTimeout(500);
// Give time for wallet connectors to initialize
await page.waitForTimeout(2_000);
// Try desktop Connect button first
const connectButton = page.locator('.connect-button--disconnected').first();
if (await connectButton.isVisible({ timeout: 5_000 })) {
console.log('[HELPER] Found desktop Connect button');
await connectButton.click();
await page.waitForTimeout(1_000);
// Click the first wallet connector
const injectedConnector = page.locator('.connectors-element').first();
if (await injectedConnector.isVisible({ timeout: 5_000 })) {
console.log('[HELPER] Clicking wallet connector...');
await injectedConnector.click();
await page.waitForTimeout(2_000);
}
} 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();
await page.waitForTimeout(1_000);
const injectedConnector = page.locator('.connectors-element').first();
if (await injectedConnector.isVisible({ timeout: 5_000 })) {
await injectedConnector.click();
await page.waitForTimeout(2_000);
}
}
}
// 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<void> {
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<string> {
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<any> {
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;
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<void> {
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<void> {
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<void> {
console.log(`[${personaName}] Attempting to stake ${amount} KRK at tax rate ${taxRateIndex}%...`);
const baseUrl = page.url().split('#')[0];
await page.goto(`${baseUrl}stake`);
2026-02-18 00:19:05 +01:00
// Wait longer for page to load and stats to initialize
await page.waitForTimeout(3_000);
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);
await page.waitForTimeout(500);
// Select tax rate
const taxSelect = page.getByRole('combobox', { name: 'Tax' });
await taxSelect.selectOption({ value: taxRateIndex });
await page.waitForTimeout(500);
// 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
}
await page.waitForTimeout(3_000);
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;
}
}