diff --git a/scripts/harb-evaluator/helpers/stake.ts b/scripts/harb-evaluator/helpers/stake.ts new file mode 100644 index 0000000..76895df --- /dev/null +++ b/scripts/harb-evaluator/helpers/stake.ts @@ -0,0 +1,182 @@ +/** + * Shared staking helpers for holdout scenarios. + * + * stakeKrk — drives the stake page UI (navigate → fill → submit → wait for Ponder). + * unstakeKrk — expands the active position collapse and clicks Unstake. + * + * Both helpers handle the password gate on first visit automatically. + */ +import type { Page } from '@playwright/test'; +import { expect } from '@playwright/test'; +import { navigateSPA } from '../../../tests/setup/navigate'; +import { getStackConfig } from '../../../tests/setup/stack'; + +// ── Internal helpers ────────────────────────────────────────────────────────── + +/** + * Navigate to /app/stake, handling the auth guard's password gate if triggered. + * The guard redirects to /login when localStorage 'authentificated' is absent. + * After a successful login, router.push('/') causes '/' → '/stake' redirect so + * we end up on the stake page without a second navigateSPA call. + */ +async function navigateToStakePage(page: Page): Promise { + await navigateSPA(page, '/app/stake'); + + // If the auth guard redirected to /login, the password input will be visible. + const passwordInput = page.getByLabel('Password'); + if (await passwordInput.isVisible()) { + console.log('[stake] Password prompt detected, entering lobsterDao...'); + await passwordInput.fill('lobsterDao'); + await page.getByRole('button', { name: 'Login' }).click(); + // router.push('/') in LoginView → '/' redirects to '/stake' → stake page loads. + await page.waitForLoadState('networkidle', { timeout: 10_000 }); + console.log('[stake] Authenticated, stake page loading'); + } +} + +/** + * Poll the Ponder GraphQL API until at least one active position exists for owner. + * Throws if no active position appears within timeoutMs. + */ +async function waitForActivePosition(graphqlUrl: string, owner: string, timeoutMs = 30_000): Promise { + const query = ` + query PositionsByOwner($owner: String!) { + positionss(where: { owner: $owner }, limit: 5) { + items { + id + status + } + } + } + `; + + const deadline = Date.now() + timeoutMs; + while (Date.now() < deadline) { + const resp = await fetch(graphqlUrl, { + method: 'POST', + headers: { 'content-type': 'application/json' }, + body: JSON.stringify({ query, variables: { owner } }), + }); + const payload = (await resp.json()) as { + data?: { positionss?: { items?: Array<{ id: string; status: string }> } }; + }; + const items = payload?.data?.positionss?.items ?? []; + const active = items.filter(p => p.status === 'Active'); + if (active.length > 0) { + console.log(`[stake] Ponder indexed ${active.length} active position(s)`); + return; + } + console.log('[stake] Waiting for Ponder to index staking position...'); + // eslint-disable-next-line no-restricted-syntax -- Polling with timeout: Ponder GraphQL is HTTP-only (no push). See AGENTS.md #Engineering Principles. + await new Promise(r => setTimeout(r, 2_000)); + } + throw new Error(`No active staking position found in Ponder GraphQL within ${timeoutMs}ms`); +} + +// ── Exported helpers ────────────────────────────────────────────────────────── + +/** + * Navigate to the stake page, fill the amount, select tax rate, and submit. + * Wallet must already be connected. App must be password-unlocked. + * + * @param page - Playwright page with injected wallet + * @param amount - KRK amount to stake (as string, e.g. '1000') + * @param taxRateIndex - Tax rate option index (0-based). Use the highest available for max tax. + */ +export async function stakeKrk(page: Page, amount: string, taxRateIndex: number): Promise { + console.log(`[stake] Staking ${amount} KRK at tax rate index ${taxRateIndex}...`); + + await navigateToStakePage(page); + + // The Token Amount slider is the readiness signal used in the e2e test. + const tokenAmountSlider = page.getByRole('slider', { name: 'Token Amount' }); + await expect(tokenAmountSlider).toBeVisible({ timeout: 15_000 }); + console.log('[stake] Stake form loaded'); + + // Fill staking amount. + const stakeAmountInput = page.getByLabel('Staking Amount'); + await expect(stakeAmountInput).toBeVisible({ timeout: 10_000 }); + await stakeAmountInput.fill(amount); + console.log(`[stake] Filled staking amount: ${amount}`); + + // Select tax rate. + const taxSelect = page.getByRole('combobox', { name: 'Tax' }); + await taxSelect.selectOption({ value: String(taxRateIndex) }); + console.log(`[stake] Tax rate index ${taxRateIndex} selected`); + + // Click the main form's stake button (not the small position-card buttons). + const stakeButton = page.getByRole('main').getByRole('button', { name: /Stake|Snatch and Stake/i }); + await expect(stakeButton).toBeVisible({ timeout: 5_000 }); + console.log('[stake] Clicking Stake button...'); + await stakeButton.click(); + + // Wait for transaction: button cycles "Stake" → "Sign Transaction"/"Waiting" → "Stake". + try { + await page + .getByRole('button', { name: /Sign Transaction|Waiting/i }) + .waitFor({ state: 'visible', timeout: 5_000 }); + console.log('[stake] Transaction initiated, waiting for completion...'); + await page + .getByRole('main') + .getByRole('button', { name: /Stake|Snatch and Stake/i }) + .waitFor({ state: 'visible', timeout: 60_000 }); + console.log('[stake] Stake transaction completed'); + } catch { + console.log('[stake] Transaction state not observed (may have completed instantly)'); + } + + // Resolve the connected account address to scope the Ponder query to this wallet. + const accountAddress = await page.evaluate(async () => { + const ethereum = (window as unknown as { ethereum: { request: (req: { method: string }) => Promise } }).ethereum; + const accounts = await ethereum.request({ method: 'eth_accounts' }); + return accounts[0].toLowerCase(); + }); + + // Poll Ponder GraphQL until the new position is indexed (indexing latency up to 30s). + const { graphqlUrl } = getStackConfig(); + await waitForActivePosition(graphqlUrl, accountAddress); + console.log('[stake] ✅ Staking complete'); +} + +/** + * Navigate to an active staking position and click Unstake. + * Assumes the wallet has exactly one active position (simplest case). + * + * @param page - Playwright page with injected wallet + */ +export async function unstakeKrk(page: Page): Promise { + console.log('[stake] Unstaking active position...'); + + await navigateToStakePage(page); + + // Wait for the active position collapse to appear. + const activeCollapse = page.locator('.f-collapse-active').first(); + await expect(activeCollapse).toBeVisible({ timeout: 15_000 }); + console.log('[stake] Active position found'); + + // FCollapse starts collapsed (isShow = false). Click the toggle icon to expand. + // The icon carries class .toggle-collapse; clicking it fires openClose() in FCollapse. + const toggleIcon = activeCollapse.locator('.toggle-collapse').first(); + await toggleIcon.click(); + console.log('[stake] Expanded position collapse'); + + // The Unstake button appears in the collapsed body when unstake.state === 'Unstakeable'. + const unstakeButton = activeCollapse.getByRole('button', { name: /^unstake$/i }); + await expect(unstakeButton).toBeVisible({ timeout: 10_000 }); + console.log('[stake] Clicking Unstake...'); + await unstakeButton.click(); + + // Wait for transaction: SignTransaction → Waiting → position detaches from DOM. + try { + await activeCollapse + .getByRole('button', { name: /Sign Transaction|Waiting/i }) + .waitFor({ state: 'visible', timeout: 5_000 }); + console.log('[stake] Unstake transaction initiated, waiting for completion...'); + await activeCollapse.waitFor({ state: 'detached', timeout: 60_000 }); + console.log('[stake] Position removed from UI'); + } catch { + console.log('[stake] Transaction state not observed (may have completed instantly)'); + } + + console.log('[stake] ✅ Unstaking complete'); +}