diff --git a/package-lock.json b/package-lock.json index 52e58d9..488f411 100644 --- a/package-lock.json +++ b/package-lock.json @@ -12,6 +12,10 @@ ], "devDependencies": { "@playwright/test": "^1.55.1", + "@typescript-eslint/eslint-plugin": "^8.45.0", + "@typescript-eslint/parser": "^8.45.0", + "eslint": "^9.36.0", + "eslint-config-prettier": "^10.1.8", "ethers": "^6.11.1", "husky": "^9.0.11", "playwright-mcp": "^0.0.12" diff --git a/scripts/harb-evaluator/helpers/stake.ts b/scripts/harb-evaluator/helpers/stake.ts new file mode 100644 index 0000000..63f898f --- /dev/null +++ b/scripts/harb-evaluator/helpers/stake.ts @@ -0,0 +1,204 @@ +/** + * 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'); + + // Race between the stake form and the login page to determine which route mounted. + // Point-in-time isVisible() is unreliable here: CSS transitions or async component + // setup can leave the element in the DOM but not yet "visible" right after networkidle. + const isLoginPage = await page + .getByLabel('Password') + .waitFor({ state: 'visible', timeout: 3_000 }) + .then(() => true) + .catch(() => false); + + if (isLoginPage) { + console.log('[stake] Password prompt detected, entering lobsterDao...'); + await page.getByLabel('Password').fill('lobsterDao'); + await page.getByRole('button', { name: 'Login' }).click(); + // router.push('/') in LoginView → '/' redirects to '/stake' → stake page loads. + // Allow extra time for the two-hop client-side navigation to settle in slow CI. + await page.waitForLoadState('networkidle', { timeout: 20_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. + * + * owner must be lowercase: the e2e tests and this helper both use address.toLowerCase() + * to match Ponder's storage format (verified against 01-acquire-and-stake.spec.ts). + */ +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`); + + // Anchored regex avoids matching the "Unstake" buttons on any expanded position cards. + const stakeButton = page.getByRole('main').getByRole('button', { name: /^(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". + // The intermediate states may be missed if the tx completes instantly (Anvil automine). + 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: /^(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. + // Guard against an empty accounts array (locked or disconnected 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' }); + if (!accounts || accounts.length === 0) { + throw new Error('[stake] eth_accounts returned empty array — wallet may be locked'); + } + 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(); + + // Observe the sign/waiting state if visible (may be missed on fast Anvil automine). + try { + await activeCollapse + .getByRole('button', { name: /Sign Transaction|Waiting/i }) + .waitFor({ state: 'visible', timeout: 5_000 }); + console.log('[stake] Unstake transaction initiated'); + } catch { + console.log('[stake] Transaction sign/waiting state not observed (may have completed instantly)'); + } + + // Always wait for the collapse to detach — this verifies the Unstake click actually + // worked and the position was removed, regardless of whether the transient states + // were observed above. + // TODO: add a Ponder waitForPositionGone poll here for holdout scenarios that assert + // off-chain state (e.g. status === 'Closed') immediately after unstaking. + await activeCollapse.waitFor({ state: 'detached', timeout: 60_000 }); + console.log('[stake] Position removed from UI'); + + console.log('[stake] ✅ Unstaking complete'); +}