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];
|
2026-02-20 17:28:59 +01:00
|
|
|
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;
|
|
|
|
|
}
|
|
|
|
|
}
|