From 4e6182acc637ad6fe914c980fe6704910b4f7b2d Mon Sep 17 00:00:00 2001 From: openhands Date: Thu, 5 Mar 2026 15:09:54 +0000 Subject: [PATCH] fix: evaluator: add stakeKrk and unstakeKrk browser helpers (#460) Co-Authored-By: Claude Sonnet 4.6 --- scripts/harb-evaluator/helpers/stake.ts | 48 ++++++++++++++++++------- 1 file changed, 35 insertions(+), 13 deletions(-) diff --git a/scripts/harb-evaluator/helpers/stake.ts b/scripts/harb-evaluator/helpers/stake.ts index 76895df..63f898f 100644 --- a/scripts/harb-evaluator/helpers/stake.ts +++ b/scripts/harb-evaluator/helpers/stake.ts @@ -22,14 +22,22 @@ import { getStackConfig } from '../../../tests/setup/stack'; 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()) { + // 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 passwordInput.fill('lobsterDao'); + await page.getByLabel('Password').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 }); + // 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'); } } @@ -37,6 +45,9 @@ async function navigateToStakePage(page: Page): Promise { /** * 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 = ` @@ -104,13 +115,14 @@ export async function stakeKrk(page: Page, amount: string, taxRateIndex: number) 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 }); + // 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 }) @@ -118,7 +130,7 @@ export async function stakeKrk(page: Page, amount: string, taxRateIndex: number) console.log('[stake] Transaction initiated, waiting for completion...'); await page .getByRole('main') - .getByRole('button', { name: /Stake|Snatch and Stake/i }) + .getByRole('button', { name: /^(Snatch and )?Stake$/i }) .waitFor({ state: 'visible', timeout: 60_000 }); console.log('[stake] Stake transaction completed'); } catch { @@ -126,9 +138,13 @@ export async function stakeKrk(page: Page, amount: string, taxRateIndex: number) } // 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(); }); @@ -166,17 +182,23 @@ export async function unstakeKrk(page: Page): Promise { console.log('[stake] Clicking Unstake...'); await unstakeButton.click(); - // Wait for transaction: SignTransaction → Waiting → position detaches from DOM. + // 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, waiting for completion...'); - await activeCollapse.waitFor({ state: 'detached', timeout: 60_000 }); - console.log('[stake] Position removed from UI'); + console.log('[stake] Unstake transaction initiated'); } catch { - console.log('[stake] Transaction state not observed (may have completed instantly)'); + 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'); }