Merge pull request 'fix: feat: Shared holdout scenario test helpers (#401)' (#405) from fix/issue-401 into master
This commit is contained in:
commit
be68018290
5 changed files with 376 additions and 182 deletions
63
scripts/harb-evaluator/helpers/assertions.ts
Normal file
63
scripts/harb-evaluator/helpers/assertions.ts
Normal file
|
|
@ -0,0 +1,63 @@
|
|||
/**
|
||||
* Shared assertion helpers for holdout scenarios.
|
||||
*
|
||||
* These wrap Playwright's `expect` with protocol-specific checks so that
|
||||
* scenario specs stay at the "what" level rather than encoding RPC details.
|
||||
*/
|
||||
import { expect } from '@playwright/test';
|
||||
import { Interface } from 'ethers';
|
||||
import { rpcCall } from './rpc';
|
||||
|
||||
// ── Internal helpers ─────────────────────────────────────────────────────────
|
||||
|
||||
async function getTokenBalance(rpcUrl: string, token: string, address: string): Promise<bigint> {
|
||||
if (token.toLowerCase() === 'eth') {
|
||||
const result = (await rpcCall(rpcUrl, 'eth_getBalance', [address, 'latest'])) as string;
|
||||
return BigInt(result);
|
||||
}
|
||||
const selector = '0x70a08231'; // balanceOf(address)
|
||||
const data = selector + address.slice(2).padStart(64, '0');
|
||||
const result = (await rpcCall(rpcUrl, 'eth_call', [{ to: token, data }, 'latest'])) as string;
|
||||
return BigInt(result);
|
||||
}
|
||||
|
||||
const POOL_ABI = [
|
||||
'function slot0() external view returns (uint160 sqrtPriceX96, int24 tick, uint16 observationIndex, uint16 observationCardinality, uint16 observationCardinalityNext, uint8 feeProtocol, bool unlocked)',
|
||||
];
|
||||
const poolIface = new Interface(POOL_ABI);
|
||||
|
||||
// ── Exported helpers ─────────────────────────────────────────────────────────
|
||||
|
||||
/**
|
||||
* Snapshot the balance of `token` for `address`, run `action`, then assert the
|
||||
* balance increased.
|
||||
*
|
||||
* @param token - An ERC-20 contract address, or the string `'eth'` for native ETH.
|
||||
*/
|
||||
export async function expectBalanceIncrease(
|
||||
rpcUrl: string,
|
||||
token: string,
|
||||
address: string,
|
||||
action: () => Promise<void>,
|
||||
): Promise<void> {
|
||||
const before = await getTokenBalance(rpcUrl, token, address);
|
||||
await action();
|
||||
const after = await getTokenBalance(rpcUrl, token, address);
|
||||
expect(after).toBeGreaterThan(before);
|
||||
}
|
||||
|
||||
/**
|
||||
* Assert that the Uniswap V3 pool at `poolAddress` is initialised.
|
||||
*
|
||||
* Checks that sqrtPriceX96 > 0, which confirms the pool has been seeded and
|
||||
* can execute swaps. Active-tick liquidity is intentionally not checked here:
|
||||
* after a sovereign exit the LiquidityManager's three range positions (Floor,
|
||||
* Anchor, Discovery) may all sit outside the current tick while the pool
|
||||
* itself remains functional.
|
||||
*/
|
||||
export async function expectPoolHasLiquidity(rpcUrl: string, poolAddress: string): Promise<void> {
|
||||
const slot0Encoded = poolIface.encodeFunctionData('slot0', []);
|
||||
const slot0Hex = (await rpcCall(rpcUrl, 'eth_call', [{ to: poolAddress, data: slot0Encoded }, 'latest'])) as string;
|
||||
const [sqrtPriceX96] = poolIface.decodeFunctionResult('slot0', slot0Hex);
|
||||
expect(sqrtPriceX96).toBeGreaterThan(0n);
|
||||
}
|
||||
17
scripts/harb-evaluator/helpers/rpc.ts
Normal file
17
scripts/harb-evaluator/helpers/rpc.ts
Normal file
|
|
@ -0,0 +1,17 @@
|
|||
/**
|
||||
* Shared JSON-RPC utility for holdout helpers.
|
||||
*
|
||||
* Exported from one place so wallet.ts, assertions.ts, and future helpers
|
||||
* share a single implementation rather than embedding the same fetch +
|
||||
* error-check block in each file.
|
||||
*/
|
||||
export async function rpcCall(rpcUrl: string, method: string, params: unknown[]): Promise<unknown> {
|
||||
const resp = await fetch(rpcUrl, {
|
||||
method: 'POST',
|
||||
headers: { 'content-type': 'application/json' },
|
||||
body: JSON.stringify({ jsonrpc: '2.0', id: Date.now(), method, params }),
|
||||
});
|
||||
const payload = await resp.json();
|
||||
if (payload.error) throw new Error(`RPC ${method}: ${payload.error.message}`);
|
||||
return payload.result;
|
||||
}
|
||||
183
scripts/harb-evaluator/helpers/swap.ts
Normal file
183
scripts/harb-evaluator/helpers/swap.ts
Normal file
|
|
@ -0,0 +1,183 @@
|
|||
/**
|
||||
* Shared swap helpers for holdout scenarios.
|
||||
*
|
||||
* buyKrk — drives the real get-krk page swap widget (UI path, requires #393 fix).
|
||||
* sellAllKrk — submits approve + exactInputSingle directly via window.ethereum
|
||||
* (no UI widget — the Uniswap router handles the on-chain leg).
|
||||
*/
|
||||
import type { Page } from '@playwright/test';
|
||||
import { expect } from '@playwright/test';
|
||||
import { Interface } from 'ethers';
|
||||
import { navigateSPA } from '../../../tests/setup/navigate';
|
||||
import { rpcCall } from './rpc';
|
||||
|
||||
// Infrastructure addresses stable across Anvil forks of Base Sepolia
|
||||
const SWAP_ROUTER = '0x94cC0AaC535CCDB3C01d6787D6413C739ae12bc4';
|
||||
const WETH = '0x4200000000000000000000000000000000000006';
|
||||
const POOL_FEE = 10_000; // 1% tier used by the KRAIKEN pool
|
||||
|
||||
const ERC20_ABI = ['function approve(address spender, uint256 amount) returns (bool)'];
|
||||
const ROUTER_ABI = [
|
||||
'function exactInputSingle((address tokenIn, address tokenOut, uint24 fee, address recipient, uint256 amountIn, uint256 amountOutMinimum, uint160 sqrtPriceLimitX96) params) payable returns (uint256 amountOut)',
|
||||
];
|
||||
|
||||
// ── Internal helpers ─────────────────────────────────────────────────────────
|
||||
|
||||
/** Read an ERC-20 balanceOf in the Node.js context via direct RPC. */
|
||||
async function erc20BalanceOf(rpcUrl: string, tokenAddress: string, account: string): Promise<bigint> {
|
||||
const selector = '0x70a08231'; // balanceOf(address)
|
||||
const data = selector + account.slice(2).padStart(64, '0');
|
||||
return BigInt((await rpcCall(rpcUrl, 'eth_call', [{ to: tokenAddress, data }, 'latest'])) as string);
|
||||
}
|
||||
|
||||
/**
|
||||
* Poll eth_getTransactionReceipt until the tx is mined or maxAttempts exceeded.
|
||||
* Anvil with automine resolves almost immediately; the loop guards against
|
||||
* instances configured with a block interval or high RPC latency.
|
||||
*
|
||||
* Throws if the transaction was mined but reverted (status 0x0) so callers
|
||||
* get a clear failure rather than a confusing downstream balance-assertion error.
|
||||
*/
|
||||
async function waitForReceipt(rpcUrl: string, txHash: string, maxAttempts = 20): Promise<void> {
|
||||
for (let i = 0; i < maxAttempts; i++) {
|
||||
const receipt = (await rpcCall(rpcUrl, 'eth_getTransactionReceipt', [txHash])) as Record<
|
||||
string,
|
||||
unknown
|
||||
> | null;
|
||||
if (receipt !== null) {
|
||||
if (receipt.status === '0x0') {
|
||||
throw new Error(`Transaction ${txHash} reverted (status 0x0)`);
|
||||
}
|
||||
return; // status === '0x1' — success
|
||||
}
|
||||
await new Promise(r => setTimeout(r, 500));
|
||||
}
|
||||
throw new Error(`Transaction ${txHash} not mined after ${maxAttempts * 500}ms`);
|
||||
}
|
||||
|
||||
// ── Public config type ───────────────────────────────────────────────────────
|
||||
|
||||
export interface SellConfig {
|
||||
/** Anvil JSON-RPC endpoint (used to wait for receipt and query token balances). */
|
||||
rpcUrl: string;
|
||||
/** Deployed KRAIKEN (KRK) ERC-20 contract address. */
|
||||
krkAddress: string;
|
||||
/** EOA address that holds the KRK tokens and will send the transactions. */
|
||||
accountAddress: string;
|
||||
}
|
||||
|
||||
// ── Exported helpers ─────────────────────────────────────────────────────────
|
||||
|
||||
/**
|
||||
* Navigate to the get-krk page, fill the ETH amount, click Buy KRK, and wait for
|
||||
* the swap widget to return to its idle state ("Buy KRK" button re-enabled).
|
||||
*
|
||||
* Uses the real LocalSwapWidget UI path (requires the #393 fill() fix).
|
||||
* Wallet must already be connected before calling this.
|
||||
*/
|
||||
export async function buyKrk(page: Page, ethAmount: string): Promise<void> {
|
||||
console.log(`[swap] Buying KRK with ${ethAmount} ETH via get-krk page...`);
|
||||
await navigateSPA(page, '/app/get-krk');
|
||||
await expect(page.getByRole('heading', { name: 'Get $KRK Tokens' })).toBeVisible({ timeout: 10_000 });
|
||||
|
||||
const swapInput = page.getByLabel('ETH to spend');
|
||||
await expect(swapInput).toBeVisible({ timeout: 15_000 });
|
||||
|
||||
await swapInput.fill(ethAmount);
|
||||
await page.waitForTimeout(500);
|
||||
|
||||
const buyButton = page.getByRole('button', { name: 'Buy KRK' });
|
||||
await expect(buyButton).toBeVisible({ timeout: 5_000 });
|
||||
|
||||
await page.screenshot({ path: 'test-results/holdout-before-buy.png' });
|
||||
console.log('[swap] Clicking Buy KRK...');
|
||||
await buyButton.click();
|
||||
|
||||
// Button cycles: idle "Buy KRK" → "Submitting…" → idle "Buy KRK"
|
||||
try {
|
||||
await page.getByRole('button', { name: /Submitting/i }).waitFor({ state: 'visible', timeout: 5_000 });
|
||||
console.log('[swap] Swap in progress...');
|
||||
await page.getByRole('button', { name: 'Buy KRK' }).waitFor({ state: 'visible', timeout: 60_000 });
|
||||
console.log('[swap] Swap completed');
|
||||
} catch {
|
||||
// Swap completed before the Submitting state could be observed
|
||||
console.log('[swap] Button state not observed (swap may have completed instantly)');
|
||||
await page.waitForTimeout(2_000);
|
||||
}
|
||||
|
||||
await page.screenshot({ path: 'test-results/holdout-after-buy.png' });
|
||||
}
|
||||
|
||||
/**
|
||||
* Query the current KRK balance, then approve the Uniswap router and swap
|
||||
* all KRK back to WETH via on-chain transactions submitted through the
|
||||
* injected window.ethereum provider.
|
||||
*
|
||||
* This is the "sovereign exit" path — it bypasses the UI swap widget and
|
||||
* sends transactions directly so the test is not gated on the sell-side UI.
|
||||
*
|
||||
* Logs a warning if the WETH balance does not increase after the swap, which
|
||||
* indicates the pool returned 0 output (possible with amountOutMinimum: 0n on
|
||||
* a partially-drained pool).
|
||||
*/
|
||||
export async function sellAllKrk(page: Page, config: SellConfig): Promise<void> {
|
||||
const krkBalance = await erc20BalanceOf(config.rpcUrl, config.krkAddress, config.accountAddress);
|
||||
if (krkBalance === 0n) throw new Error('sellAllKrk: KRK balance is 0 — nothing to sell');
|
||||
|
||||
console.log(`[swap] Selling ${krkBalance} KRK...`);
|
||||
|
||||
const wethBefore = await erc20BalanceOf(config.rpcUrl, WETH, config.accountAddress);
|
||||
|
||||
const erc20Iface = new Interface(ERC20_ABI);
|
||||
const routerIface = new Interface(ROUTER_ABI);
|
||||
|
||||
const approveData = erc20Iface.encodeFunctionData('approve', [
|
||||
SWAP_ROUTER,
|
||||
BigInt('0xffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffff'),
|
||||
]);
|
||||
|
||||
const swapData = routerIface.encodeFunctionData('exactInputSingle', [
|
||||
{
|
||||
tokenIn: config.krkAddress,
|
||||
tokenOut: WETH,
|
||||
fee: POOL_FEE,
|
||||
recipient: config.accountAddress,
|
||||
amountIn: krkBalance,
|
||||
amountOutMinimum: 0n,
|
||||
sqrtPriceLimitX96: 0n,
|
||||
},
|
||||
]);
|
||||
|
||||
// Step 1: approve KRK spend allowance to the Uniswap router
|
||||
console.log('[swap] Approving KRK to router...');
|
||||
const approveTxHash = await page.evaluate(
|
||||
({ krkAddr, data, from }: { krkAddr: string; data: string; from: string }) =>
|
||||
(window.ethereum as any).request({
|
||||
method: 'eth_sendTransaction',
|
||||
params: [{ from, to: krkAddr, data, gas: '0x30000' }],
|
||||
}) as Promise<string>,
|
||||
{ krkAddr: config.krkAddress, data: approveData, from: config.accountAddress },
|
||||
);
|
||||
await waitForReceipt(config.rpcUrl, approveTxHash);
|
||||
console.log('[swap] Approve mined');
|
||||
|
||||
// Step 2: swap KRK → WETH via the Uniswap V3 router
|
||||
console.log('[swap] Swapping KRK → WETH (exit)...');
|
||||
const swapTxHash = await page.evaluate(
|
||||
({ routerAddr, data, from }: { routerAddr: string; data: string; from: string }) =>
|
||||
(window.ethereum as any).request({
|
||||
method: 'eth_sendTransaction',
|
||||
params: [{ from, to: routerAddr, data, gas: '0x80000' }],
|
||||
}) as Promise<string>,
|
||||
{ routerAddr: SWAP_ROUTER, data: swapData, from: config.accountAddress },
|
||||
);
|
||||
await waitForReceipt(config.rpcUrl, swapTxHash);
|
||||
console.log('[swap] Swap mined');
|
||||
|
||||
const wethAfter = await erc20BalanceOf(config.rpcUrl, WETH, config.accountAddress);
|
||||
if (wethAfter <= wethBefore) {
|
||||
console.warn('[swap] WARNING: WETH balance did not increase after sell — pool may have returned 0 output');
|
||||
} else {
|
||||
console.log(`[swap] Received ${wethAfter - wethBefore} WETH`);
|
||||
}
|
||||
}
|
||||
96
scripts/harb-evaluator/helpers/wallet.ts
Normal file
96
scripts/harb-evaluator/helpers/wallet.ts
Normal file
|
|
@ -0,0 +1,96 @@
|
|||
/**
|
||||
* Shared wallet helpers for holdout scenarios.
|
||||
*
|
||||
* Functions here operate in the Playwright Node.js context (not the browser).
|
||||
* UI interactions use Playwright locators; on-chain reads use direct RPC calls
|
||||
* so that tests do not depend on the app's wallet state for balance queries.
|
||||
*/
|
||||
import type { Page } from '@playwright/test';
|
||||
import { expect } from '@playwright/test';
|
||||
import { rpcCall } from './rpc';
|
||||
|
||||
// ── Balance readers ──────────────────────────────────────────────────────────
|
||||
|
||||
/** Return the native ETH balance (in wei) of `address`. */
|
||||
export async function getEthBalance(rpcUrl: string, address: string): Promise<bigint> {
|
||||
const result = (await rpcCall(rpcUrl, 'eth_getBalance', [address, 'latest'])) as string;
|
||||
return BigInt(result);
|
||||
}
|
||||
|
||||
/** Return the ERC-20 KRK balance (in wei) of `address` on the given token contract. */
|
||||
export async function getKrkBalance(rpcUrl: string, krkAddress: string, address: string): Promise<bigint> {
|
||||
const selector = '0x70a08231'; // balanceOf(address)
|
||||
const data = selector + address.slice(2).padStart(64, '0');
|
||||
const result = (await rpcCall(rpcUrl, 'eth_call', [{ to: krkAddress, data }, 'latest'])) as string;
|
||||
return BigInt(result);
|
||||
}
|
||||
|
||||
// ── UI helpers ───────────────────────────────────────────────────────────────
|
||||
|
||||
/**
|
||||
* Connect the test wallet via the desktop UI flow.
|
||||
*
|
||||
* Expects the page to already be on the web app with the navbar visible.
|
||||
* The injected wallet provider must already be set up by createWalletContext.
|
||||
* Verifies connection by waiting for the connected button to appear in the navbar.
|
||||
*/
|
||||
export async function connectWallet(page: Page): Promise<void> {
|
||||
// Trigger resize so Vue's useMobile composable re-evaluates with screen.width=1280.
|
||||
await page.evaluate(() => window.dispatchEvent(new Event('resize')));
|
||||
await page.waitForTimeout(2_000);
|
||||
|
||||
const screenWidth = await page.evaluate(() => window.screen.width);
|
||||
console.log(`[wallet] screen.width = ${screenWidth}`);
|
||||
|
||||
let panelOpened = false;
|
||||
|
||||
const connectButton = page.locator('.connect-button--disconnected').first();
|
||||
if (await connectButton.isVisible({ timeout: 5_000 })) {
|
||||
console.log('[wallet] Found desktop Connect button, clicking...');
|
||||
await connectButton.click();
|
||||
panelOpened = true;
|
||||
} else {
|
||||
// Fallback: mobile login icon. Dead code when screen.width=1280 but kept for safety.
|
||||
const mobileLoginIcon = page.locator('.navbar-end svg').first();
|
||||
if (await mobileLoginIcon.isVisible({ timeout: 2_000 })) {
|
||||
console.log('[wallet] Found mobile login icon, clicking...');
|
||||
await mobileLoginIcon.click();
|
||||
panelOpened = true;
|
||||
}
|
||||
}
|
||||
|
||||
if (panelOpened) {
|
||||
await page.waitForTimeout(1_000);
|
||||
const injectedConnector = page.locator('.connectors-element').first();
|
||||
if (await injectedConnector.isVisible({ timeout: 5_000 })) {
|
||||
console.log('[wallet] Clicking wallet connector...');
|
||||
await injectedConnector.click();
|
||||
await page.waitForTimeout(2_000);
|
||||
} else {
|
||||
console.log('[wallet] WARNING: No wallet connector found in panel');
|
||||
}
|
||||
}
|
||||
|
||||
// The navbar shows .connect-button--connected once wagmi reports status=connected.
|
||||
await expect(page.locator('.connect-button--connected').first()).toBeVisible({ timeout: 15_000 });
|
||||
console.log('[wallet] Wallet connected');
|
||||
}
|
||||
|
||||
/**
|
||||
* Disconnect the wallet by opening the connected panel and clicking the logout icon.
|
||||
*
|
||||
* Verifies disconnection by waiting for the Connect button to reappear.
|
||||
*/
|
||||
export async function disconnectWallet(page: Page): Promise<void> {
|
||||
const connectedButton = page.locator('.connect-button--connected').first();
|
||||
await expect(connectedButton).toBeVisible({ timeout: 5_000 });
|
||||
await connectedButton.click();
|
||||
|
||||
// Panel opens showing .connected-header-logout (img alt="Logout").
|
||||
const logoutIcon = page.locator('.connected-header-logout').first();
|
||||
await expect(logoutIcon).toBeVisible({ timeout: 5_000 });
|
||||
await logoutIcon.click();
|
||||
|
||||
await expect(page.locator('.connect-button--disconnected').first()).toBeVisible({ timeout: 10_000 });
|
||||
console.log('[wallet] Wallet disconnected');
|
||||
}
|
||||
|
|
@ -4,72 +4,23 @@
|
|||
* Verifies the core protocol invariant: a user can ALWAYS exit their position
|
||||
* by buying KRK through the in-app swap widget and then selling it back.
|
||||
*
|
||||
* Reuses tests/setup/ infrastructure — no new wallet or navigation helpers.
|
||||
* Reuses tests/setup/ infrastructure and the shared helpers in
|
||||
* scripts/harb-evaluator/helpers/ — no inline wallet, swap, or balance logic.
|
||||
*
|
||||
* Account 0 from the Anvil test mnemonic is used (same as e2e tests).
|
||||
* Deploy scripts also use Account 0, but each test run gets a fresh Anvil stack,
|
||||
* so no collision occurs.
|
||||
*/
|
||||
import { expect, test } from '@playwright/test';
|
||||
import { Interface, Wallet } from 'ethers';
|
||||
import { Wallet } from 'ethers';
|
||||
import { createWalletContext } from '../../../../tests/setup/wallet-provider';
|
||||
import { getStackConfig } from '../../../../tests/setup/stack';
|
||||
import { navigateSPA } from '../../../../tests/setup/navigate';
|
||||
import { connectWallet, getKrkBalance } from '../../helpers/wallet';
|
||||
import { buyKrk, sellAllKrk } from '../../helpers/swap';
|
||||
|
||||
// Anvil account 0 — same as e2e tests (deploy uses it but state is reset per stack)
|
||||
const PK = '0xac0974bec39a17e36ba4a6b4d238ff944bacb478cbed5efcae784d7bf4f2ff80';
|
||||
const ACCOUNT = new Wallet(PK);
|
||||
const ACCOUNT_ADDRESS = ACCOUNT.address;
|
||||
|
||||
// Infrastructure addresses that are stable across Anvil forks of Base Sepolia
|
||||
const SWAP_ROUTER = '0x94cC0AaC535CCDB3C01d6787D6413C739ae12bc4';
|
||||
const WETH = '0x4200000000000000000000000000000000000006';
|
||||
const POOL_FEE = 10_000; // 1% tier used by KRAIKEN pool
|
||||
|
||||
// ── RPC helpers (Node.js context) ──────────────────────────────────────────
|
||||
|
||||
async function rpcCall(rpcUrl: string, method: string, params: unknown[]): Promise<unknown> {
|
||||
const resp = await fetch(rpcUrl, {
|
||||
method: 'POST',
|
||||
headers: { 'content-type': 'application/json' },
|
||||
body: JSON.stringify({ jsonrpc: '2.0', id: Date.now(), method, params }),
|
||||
});
|
||||
const payload = await resp.json();
|
||||
if (payload.error) throw new Error(`RPC ${method}: ${payload.error.message}`);
|
||||
return payload.result;
|
||||
}
|
||||
|
||||
async function getKrkBalance(rpcUrl: string, krkAddress: string, account: string): Promise<bigint> {
|
||||
const selector = '0x70a08231'; // balanceOf(address)
|
||||
const data = selector + account.slice(2).padStart(64, '0');
|
||||
const result = (await rpcCall(rpcUrl, 'eth_call', [{ to: krkAddress, data }, 'latest'])) as string;
|
||||
return BigInt(result);
|
||||
}
|
||||
|
||||
/**
|
||||
* Poll eth_getTransactionReceipt until the tx is mined or maxAttempts exceeded.
|
||||
* Anvil with automine mines synchronously before returning the tx hash, so this
|
||||
* resolves almost immediately. The explicit check guards against Anvil instances
|
||||
* configured with block intervals or high RPC latency.
|
||||
*/
|
||||
async function waitForReceipt(rpcUrl: string, txHash: string, maxAttempts = 20): Promise<void> {
|
||||
for (let i = 0; i < maxAttempts; i++) {
|
||||
const receipt = await rpcCall(rpcUrl, 'eth_getTransactionReceipt', [txHash]);
|
||||
if (receipt !== null) return;
|
||||
await new Promise(r => setTimeout(r, 500));
|
||||
}
|
||||
throw new Error(`Transaction ${txHash} not mined after ${maxAttempts * 500}ms`);
|
||||
}
|
||||
|
||||
// ── ABI helpers for the sell path ──────────────────────────────────────────
|
||||
|
||||
const ERC20_ABI = ['function approve(address spender, uint256 amount) returns (bool)'];
|
||||
|
||||
const ROUTER_ABI = [
|
||||
'function exactInputSingle((address tokenIn, address tokenOut, uint24 fee, address recipient, uint256 amountIn, uint256 amountOutMinimum, uint160 sqrtPriceLimitX96) params) payable returns (uint256 amountOut)',
|
||||
];
|
||||
|
||||
// ── Test ───────────────────────────────────────────────────────────────────
|
||||
const ACCOUNT_ADDRESS = new Wallet(PK).address;
|
||||
|
||||
test('I can always leave', async ({ browser }) => {
|
||||
const config = getStackConfig();
|
||||
|
|
@ -88,145 +39,29 @@ test('I can always leave', async ({ browser }) => {
|
|||
await page.goto(`${config.webAppUrl}/app/`, { waitUntil: 'domcontentloaded' });
|
||||
await expect(page.locator('.navbar-title').first()).toBeVisible({ timeout: 30_000 });
|
||||
|
||||
// Force desktop-mode recalculation (wallet-provider sets screen.width = 1280)
|
||||
await page.evaluate(() => window.dispatchEvent(new Event('resize')));
|
||||
await page.waitForTimeout(2_000);
|
||||
|
||||
// ── 2. Connect wallet via the UI (matches e2e/01 pattern) ────────────
|
||||
// ── 2. Connect wallet via the UI ─────────────────────────────────────
|
||||
console.log('[TEST] Connecting wallet...');
|
||||
let panelOpened = false;
|
||||
|
||||
const connectButton = page.locator('.connect-button--disconnected').first();
|
||||
if (await connectButton.isVisible({ timeout: 5_000 })) {
|
||||
console.log('[TEST] Found desktop Connect button, clicking...');
|
||||
await connectButton.click();
|
||||
panelOpened = true;
|
||||
} else {
|
||||
const screenWidth = await page.evaluate(() => window.screen.width);
|
||||
console.log(`[TEST] DEBUG: screen.width = ${screenWidth}`);
|
||||
// Fallback to mobile login icon (dead code — wallet-provider always sets screen.width=1280, copied from e2e/01 for consistency)
|
||||
const mobileLoginIcon = page.locator('.navbar-end svg').first();
|
||||
if (await mobileLoginIcon.isVisible({ timeout: 2_000 })) {
|
||||
console.log('[TEST] Found mobile login icon, clicking...');
|
||||
await mobileLoginIcon.click();
|
||||
panelOpened = true;
|
||||
} else {
|
||||
console.log('[TEST] No Connect button or mobile icon — wallet may already be connected');
|
||||
}
|
||||
}
|
||||
|
||||
if (panelOpened) {
|
||||
await page.waitForTimeout(1_000);
|
||||
console.log('[TEST] Looking for wallet connector in panel...');
|
||||
const injectedConnector = page.locator('.connectors-element').first();
|
||||
if (await injectedConnector.isVisible({ timeout: 5_000 })) {
|
||||
console.log('[TEST] Clicking wallet connector...');
|
||||
await injectedConnector.click();
|
||||
await page.waitForTimeout(2_000);
|
||||
} else {
|
||||
console.log('[TEST] WARNING: No wallet connector found in panel');
|
||||
}
|
||||
}
|
||||
|
||||
// Confirm wallet address is displayed in the navbar
|
||||
const addrPrefix = ACCOUNT_ADDRESS.slice(0, 8);
|
||||
await expect(page.getByText(new RegExp(addrPrefix, 'i')).first()).toBeVisible({ timeout: 15_000 });
|
||||
console.log('[TEST] Wallet connected');
|
||||
|
||||
// ── 3. Navigate to cheats page (has swap widget, same as e2e tests) ──
|
||||
console.log('[TEST] Navigating to cheats...');
|
||||
await navigateSPA(page, '/app/cheats');
|
||||
await expect(page.getByRole('heading', { name: 'Cheat Console' })).toBeVisible({ timeout: 10_000 });
|
||||
|
||||
// The cheats page has an inline FInput with label 'ETH to spend'
|
||||
const swapInput = page.getByLabel('ETH to spend');
|
||||
await expect(swapInput).toBeVisible({ timeout: 15_000 });
|
||||
console.log('[TEST] Swap widget visible');
|
||||
await connectWallet(page);
|
||||
|
||||
// ── 3. Buy KRK via the get-krk page swap widget ───────────────────────
|
||||
const krkBefore = await getKrkBalance(config.rpcUrl, config.contracts.Kraiken, ACCOUNT_ADDRESS);
|
||||
console.log(`[TEST] KRK balance before buy: ${krkBefore}`);
|
||||
|
||||
// Use fill() which triggers input events for Vue v-model binding
|
||||
await swapInput.fill('0.1');
|
||||
await page.waitForTimeout(500);
|
||||
await buyKrk(page, '0.1');
|
||||
|
||||
const buyButton = page.getByRole('button', { name: 'Buy' }).last();
|
||||
await expect(buyButton).toBeVisible();
|
||||
// Debug: screenshot before clicking
|
||||
await page.screenshot({ path: 'test-results/holdout-before-buy.png' });
|
||||
console.log('[TEST] Clicking Buy KRK...');
|
||||
await buyButton.click();
|
||||
await page.waitForTimeout(3_000);
|
||||
await page.screenshot({ path: 'test-results/holdout-after-buy.png' });
|
||||
|
||||
// Wait for the swap to complete (button cycles through "Submitting…" → "Buy")
|
||||
try {
|
||||
await page.getByRole('button', { name: /Submitting/i }).waitFor({ state: 'visible', timeout: 5_000 });
|
||||
console.log('[TEST] Swap in progress...');
|
||||
await page.getByRole('button', { name: 'Buy' }).last().waitFor({ state: 'visible', timeout: 60_000 });
|
||||
console.log('[TEST] Swap completed');
|
||||
} catch (err) {
|
||||
console.log(`[TEST] Button state not observed (may be instant): ${err}`);
|
||||
}
|
||||
await page.waitForTimeout(2_000);
|
||||
|
||||
// ── 4. Verify KRK was received ────────────────────────────────────
|
||||
const krkAfterBuy = await getKrkBalance(config.rpcUrl, config.contracts.Kraiken, ACCOUNT_ADDRESS);
|
||||
console.log(`[TEST] KRK balance after buy: ${krkAfterBuy}`);
|
||||
expect(krkAfterBuy).toBeGreaterThan(krkBefore);
|
||||
console.log('[TEST] ✅ KRK received');
|
||||
|
||||
// ── 5. Sell all KRK back (sovereign exit) ───────────────────────────
|
||||
// Encode approve + exactInputSingle calldata in Node.js, then send via
|
||||
// the injected window.ethereum wallet provider (tests/setup/wallet-provider).
|
||||
console.log('[TEST] Encoding sell transactions...');
|
||||
const erc20Iface = new Interface(ERC20_ABI);
|
||||
const routerIface = new Interface(ROUTER_ABI);
|
||||
// ── 4. Sell all KRK back (sovereign exit) ────────────────────────────
|
||||
await sellAllKrk(page, {
|
||||
rpcUrl: config.rpcUrl,
|
||||
krkAddress: config.contracts.Kraiken,
|
||||
accountAddress: ACCOUNT_ADDRESS,
|
||||
});
|
||||
|
||||
const approveData = erc20Iface.encodeFunctionData('approve', [
|
||||
SWAP_ROUTER,
|
||||
BigInt('0xffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffff'),
|
||||
]);
|
||||
|
||||
const swapData = routerIface.encodeFunctionData('exactInputSingle', [
|
||||
{
|
||||
tokenIn: config.contracts.Kraiken,
|
||||
tokenOut: WETH,
|
||||
fee: POOL_FEE,
|
||||
recipient: ACCOUNT_ADDRESS,
|
||||
amountIn: krkAfterBuy,
|
||||
amountOutMinimum: 0n,
|
||||
sqrtPriceLimitX96: 0n,
|
||||
},
|
||||
]);
|
||||
|
||||
// Step 5a: approve KRK to the Uniswap router; wait for on-chain confirmation
|
||||
console.log('[TEST] Approving KRK to router...');
|
||||
const approveTxHash = await page.evaluate(
|
||||
({ krkAddr, data, from }: { krkAddr: string; data: string; from: string }) =>
|
||||
(window.ethereum as any).request({
|
||||
method: 'eth_sendTransaction',
|
||||
params: [{ from, to: krkAddr, data, gas: '0x30000' }],
|
||||
}) as Promise<string>,
|
||||
{ krkAddr: config.contracts.Kraiken, data: approveData, from: ACCOUNT_ADDRESS },
|
||||
);
|
||||
await waitForReceipt(config.rpcUrl, approveTxHash);
|
||||
console.log('[TEST] Approve mined');
|
||||
|
||||
// Step 5b: swap KRK → WETH; wait for on-chain confirmation
|
||||
console.log('[TEST] Swapping KRK → WETH (exit)...');
|
||||
const swapTxHash = await page.evaluate(
|
||||
({ routerAddr, data, from }: { routerAddr: string; data: string; from: string }) =>
|
||||
(window.ethereum as any).request({
|
||||
method: 'eth_sendTransaction',
|
||||
params: [{ from, to: routerAddr, data, gas: '0x80000' }],
|
||||
}) as Promise<string>,
|
||||
{ routerAddr: SWAP_ROUTER, data: swapData, from: ACCOUNT_ADDRESS },
|
||||
);
|
||||
await waitForReceipt(config.rpcUrl, swapTxHash);
|
||||
console.log('[TEST] Swap mined');
|
||||
|
||||
// ── 6. Assert KRK was sold ────────────────────────────────────────
|
||||
// ── 5. Assert KRK was sold ────────────────────────────────────────────
|
||||
const krkAfterSell = await getKrkBalance(config.rpcUrl, config.contracts.Kraiken, ACCOUNT_ADDRESS);
|
||||
console.log(`[TEST] KRK balance after sell: ${krkAfterSell}`);
|
||||
expect(krkAfterSell).toBeLessThan(krkAfterBuy);
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue