From 8356047c8f1c0e6676f84704cafd4ee004fecb4c Mon Sep 17 00:00:00 2001 From: Liam Horne Date: Tue, 13 Oct 2026 21:56:15 -0700 Subject: [PATCH] fix(e2e): stop demo balance footers from rate-limiting the faucet The balances footer watched Transfer events with two filters per token, so the faucet page (5 tokens) polled rpc.moderato.tempo.xyz with 10 filters every 500ms and read ~10 metadata fields per token on connect. viem retries each 429 up to 16 times, so a connected page sent ~5,700 RPC requests in 2 minutes and the RPC's per-client limit rejected every tempo_fundAddress attempt. The step showed the error within seconds, but expectDemoSuccess no longer saw it: #962 replaced the step's bg-destructiveTint class that its locator matched. - Watch transfers for every footer token with one filter per direction and read only each token's symbol. - Render step errors with role="alert" and locate them by role, so live tests fail fast with the step's message again. - Make both faucet tests use expectDemoSuccess, and request funds after the footer's initial balance reads finish. - Unit-test the footer's watcher shape and the step's alert role. Co-Authored-By: Liam Horne <1933029+snario@users.noreply.github.com> Co-Authored-By: Claude Opus 5.5 --- e2e/demo-success.test.ts | 10 +-- e2e/faucet.test.ts | 28 ++++--- e2e/helpers.ts | 3 +- src/components/guides/Demo.test.tsx | 120 ++++++++++++++++++++++++++++ src/components/guides/Demo.tsx | 86 +++++++++++--------- 5 files changed, 193 insertions(+), 54 deletions(-) create mode 100644 src/components/guides/Demo.test.tsx diff --git a/e2e/demo-success.test.ts b/e2e/demo-success.test.ts index 270c901b3..ee9dcbb7c 100644 --- a/e2e/demo-success.test.ts +++ b/e2e/demo-success.test.ts @@ -9,7 +9,7 @@ test('accepts a successful demo action', async ({ page }) => { test('fails promptly when a demo reports a terminal error', async ({ page }) => { test.setTimeout(5_000) await page.setContent( - '
Socket closed
', + '
Socket closed
', ) await expect( expectDemoSuccess(page, page.getByRole('link', { name: 'View receipt' }), 60_000), @@ -18,7 +18,7 @@ test('fails promptly when a demo reports a terminal error', async ({ page }) => test('rejects a demo error even when a success indicator is also visible', async ({ page }) => { await page.setContent( - '
Socket closed
', + '
Socket closed
', ) await expect( expectDemoSuccess(page, page.getByRole('button', { name: 'Add more funds' })), @@ -27,14 +27,14 @@ test('rejects a demo error even when a success indicator is also visible', async test('ignores hidden error content when the demo succeeds', async ({ page }) => { await page.setContent( - '', + '', ) await expectDemoSuccess(page, page.getByRole('button', { name: 'Add more funds' })) }) -test('does not mistake destructive action buttons for errors', async ({ page }) => { +test('does not mistake alerts nested in step content for step errors', async ({ page }) => { await page.setContent( - '
', + '

Enter a valid address

', ) await expectDemoSuccess(page, page.getByRole('button', { name: 'Add more funds' })) }) diff --git a/e2e/faucet.test.ts b/e2e/faucet.test.ts index 76fa4bbb9..ef64d41f7 100644 --- a/e2e/faucet.test.ts +++ b/e2e/faucet.test.ts @@ -1,4 +1,5 @@ import { expect, test } from '@playwright/test' +import { expectDemoSuccess, getDemoStep } from './helpers' test('fund an address via faucet', async ({ page }) => { test.setTimeout(120000) @@ -19,7 +20,7 @@ test('fund an address via faucet', async ({ page }) => { // Confirm the explorer link resolves to the funded account's transfers const transfersLink = page.getByRole('link', { name: 'View transfers' }) - await expect(transfersLink).toBeVisible({ timeout: 90000 }) + await expectDemoSuccess(page, transfersLink) await expect(transfersLink).toHaveAttribute( 'href', 'https://explore.testnet.tempo.xyz/address/0xbeefcafe54750903ac1c8909323af7beb21ea2cb?tab=transfers', @@ -48,10 +49,7 @@ test('fund a connected passkey wallet via faucet', async ({ page }) => { await expect(tab).toBeVisible({ timeout: 90000 }) await tab.click() - const connectStep = page - .locator('[data-active][data-completed]') - .filter({ hasText: 'Connect your wallet.' }) - .first() + const connectStep = getDemoStep(page, 'Connect your wallet.') const connectButton = connectStep.getByRole('button').first() await expect(connectButton).toBeVisible({ timeout: 30000 }) await expect(connectButton).toBeEnabled() @@ -61,18 +59,24 @@ test('fund a connected passkey wallet via faucet', async ({ page }) => { timeout: 30000, }) - const addFundsStep = page - .locator('[data-active][data-completed]') - .filter({ hasText: 'Add testnet funds to your wallet.' }) - .first() + // Request funds once the demo's initial balance reads finish, as a reader would, so the + // faucet call doesn't compete with them for the public RPC's per-client rate limit. + const balances = page.getByText('Personal balances', { exact: true }).locator('..') + for (const symbol of ['OUSD', 'PathUSD', 'AlphaUSD', 'BetaUSD', 'ThetaUSD']) + await expect(balances).toContainText(symbol, { timeout: 30000 }) + + const addFundsStep = getDemoStep(page, 'Add testnet funds to your wallet.') const addFundsButton = addFundsStep.getByRole('button', { name: 'Add funds' }) await expect(addFundsButton).toBeVisible({ timeout: 30000 }) await expect(addFundsButton).toBeEnabled() await addFundsButton.click() - await expect(addFundsStep.getByRole('button', { name: 'Add more funds' })).toBeVisible({ - timeout: 120000, - }) + // A faucet or RPC failure shows in the step; report it instead of waiting out the timeout. + await expectDemoSuccess( + page, + addFundsStep.getByRole('button', { name: 'Add more funds' }), + 120000, + ) } finally { await client.send('WebAuthn.removeVirtualAuthenticator', { authenticatorId }).catch(() => {}) } diff --git a/e2e/helpers.ts b/e2e/helpers.ts index 8ab3cc66d..c17d0e6d8 100644 --- a/e2e/helpers.ts +++ b/e2e/helpers.ts @@ -7,8 +7,9 @@ export function getDemoStep(page: Page, title: string | RegExp): Locator { // A failed transaction cannot produce the expected receipt. Surface the UI error // immediately instead of burning the entire success timeout before a retry. export async function expectDemoSuccess(page: Page, success: Locator, timeout = 90_000) { + // Match the step's ARIA role, not its styling classes, which change with design migrations. const errors = page - .locator('[data-active][data-completed] > div.bg-destructiveTint') + .locator('[data-active][data-completed] > [role="alert"]') .filter({ visible: true }) await expect(success.or(errors).first()).toBeVisible({ timeout }) expect(await errors.allTextContents(), 'Demo reported a terminal error').toEqual([]) diff --git a/src/components/guides/Demo.test.tsx b/src/components/guides/Demo.test.tsx new file mode 100644 index 000000000..fae6e9b5a --- /dev/null +++ b/src/components/guides/Demo.test.tsx @@ -0,0 +1,120 @@ +import { Actions } from '@wagmi/core/tempo' +import { renderToStaticMarkup } from 'react-dom/server' +import { type Address, getAddress } from 'viem' +import { tempoModerato } from 'viem/chains' +import { beforeEach, describe, expect, test, vi } from 'vitest' +import { Container, Step } from './Demo' +import { alphaUsd, betaUsd, ousd, pathUsd, thetaUsd } from './tokens' + +const state = vi.hoisted(() => ({ + invalidateQueries: vi.fn(), + watches: [] as { + address: Address[] + args: Record + enabled: boolean + onLogs: (logs: { address: Address }[]) => void + }[], +})) + +vi.mock('wagmi', () => ({ + useAccount: () => ({ address: undefined }), + useConnect: () => ({ connect: vi.fn(), isPending: false }), + useConnections: () => [], + useDisconnect: () => ({ disconnect: vi.fn() }), + useReadContract: () => ({ data: 'AlphaUSD', isPending: false }), + useWatchContractEvent: (parameters: (typeof state.watches)[number]) => { + state.watches.push(parameters) + }, +})) + +vi.mock('wagmi/tempo', () => ({ + Hooks: { + token: { + useGetBalance: () => ({ data: { amount: 1n, formatted: '1' }, isPending: false }), + }, + }, +})) + +vi.mock('@tanstack/react-query', () => ({ + useQueryClient: () => ({ invalidateQueries: state.invalidateQueries }), +})) + +vi.mock('../../wagmi.config', () => ({ + useTempoWalletConnector: () => undefined, + useWebAuthnConnector: () => undefined, +})) + +vi.mock('../../lib/posthog', () => ({ usePostHogTracking: () => ({}) })) + +// Compiled zyzz recipes need the site's compiler plugin; markup is what these tests check. +vi.mock( + './Demo.recipes', + () => + new Proxy( + {}, + { + get: (_, key) => (key === 'then' ? undefined : () => ({ className: '' })), + has: (_, key) => key !== 'then', + }, + ), +) +vi.mock('../../styles/controls', () => ({ button: () => ({}) })) +vi.mock('../Badge', () => ({ Badge: () => null })) +vi.mock('../Container', () => ({ Container: () => null })) + +const account = '0x2d0517b4567A426e92B5AB5c508D0171B7825506' +const tokens: Address[] = [ousd, pathUsd, alphaUsd, betaUsd, thetaUsd] + +beforeEach(() => { + state.invalidateQueries.mockReset() + state.watches = [] +}) + +describe('Container.BalancesFooter', () => { + test('watches transfers once per direction for every token', () => { + renderToStaticMarkup() + + expect(state.watches.map(({ address, args, enabled }) => ({ address, args, enabled }))).toEqual( + [ + { address: tokens, args: { to: account }, enabled: true }, + { address: tokens, args: { from: account }, enabled: true }, + ], + ) + }) + + test('refreshes only the balances of tokens that moved', async () => { + const { partialMatchKey } = + await vi.importActual('@tanstack/react-query') + const balanceKey = (token: Address) => + Actions.token.getBalance.queryKey({ account, token, chainId: tempoModerato.id }) + renderToStaticMarkup() + + // RPC logs carry addresses in any case. + state.watches[0]?.onLogs([{ address: getAddress(betaUsd) }]) + + expect(state.invalidateQueries).toHaveBeenCalledTimes(1) + const [{ queryKey }] = state.invalidateQueries.mock.calls[0] ?? [] + expect(partialMatchKey(balanceKey(betaUsd), queryKey)).toBe(true) + expect(partialMatchKey(balanceKey(alphaUsd), queryKey)).toBe(false) + }) + + test('does not watch transfers before an account connects', () => { + renderToStaticMarkup() + + expect(state.watches.map(({ enabled }) => enabled)).toEqual([false, false]) + }) +}) + +test('Step reports errors through the alert role that live E2E tests detect', () => { + const html = renderToStaticMarkup( + , + ) + + expect(html).toMatch(/
]*>Request exceeds defined limit\.<\/div><\/div>$/) +}) diff --git a/src/components/guides/Demo.tsx b/src/components/guides/Demo.tsx index 9fd89d76d..7e5865ea2 100644 --- a/src/components/guides/Demo.tsx +++ b/src/components/guides/Demo.tsx @@ -1,9 +1,17 @@ 'use client' import { useQueryClient } from '@tanstack/react-query' import * as React from 'react' -import type { Address, BaseError } from 'viem' +import { type Address, type BaseError, isAddressEqual } from 'viem' import { tempoModerato } from 'viem/chains' -import { useAccount, useConnect, useConnections, useDisconnect } from 'wagmi' +import { Abis } from 'viem/tempo' +import { + useAccount, + useConnect, + useConnections, + useDisconnect, + useReadContract, + useWatchContractEvent, +} from 'wagmi' import { Hooks } from 'wagmi/tempo' import { cx, type Props as StyleProps } from 'zyzz' import LucideCheck from '~icons/lucide/check' @@ -217,44 +225,20 @@ export function Container( export namespace Container { function BalancesFooterItem(props: { address: Address; token: Address }) { - const queryClient = useQueryClient() const { address, token } = props - const { - data: balance, - isPending: balanceIsPending, - queryKey: balancesKey, - } = Hooks.token.useGetBalance({ + const { data: balance, isPending: balanceIsPending } = Hooks.token.useGetBalance({ account: address, token, }) - const { data: metadata, isPending: metadataIsPending } = Hooks.token.useGetMetadata({ - token, - }) - - Hooks.token.useWatchTransfer({ - token, - args: { - to: address, - }, - onTransfer: () => { - queryClient.invalidateQueries({ queryKey: balancesKey }) - }, - enabled: !!address, - }) - - Hooks.token.useWatchTransfer({ - token, - args: { - from: address, - }, - onTransfer: () => { - queryClient.invalidateQueries({ queryKey: balancesKey }) - }, - enabled: !!address, + // The footer shows only the symbol; full token metadata costs one RPC call per field. + const { data: symbol, isPending: symbolIsPending } = useReadContract({ + address: token, + abi: Abis.tip20, + functionName: 'symbol', }) - const isPending = balanceIsPending || metadataIsPending - const isUndefined = balance === undefined || metadata === undefined + const isPending = balanceIsPending || symbolIsPending + const isUndefined = balance === undefined || symbol === undefined return (
@@ -263,16 +247,46 @@ export namespace Container { ) : ( {balance.formatted} - {metadata.symbol} + {symbol} )}
) } + // One filter per transfer direction covers every token, so a footer with five balances polls + // the public RPC as often as one with a single balance and stays under its per-client limit. + function useWatchBalanceTransfers(address: Address | undefined, tokens: Address[]) { + const queryClient = useQueryClient() + const onLogs = (logs: readonly { address: Address }[]) => { + for (const token of tokens) + if (logs.some((log) => isAddressEqual(log.address, token))) + queryClient.invalidateQueries({ queryKey: ['getBalance', { account: address, token }] }) + } + + useWatchContractEvent({ + address: tokens, + abi: Abis.tip20, + eventName: 'Transfer', + args: { to: address }, + enabled: !!address, + onLogs, + }) + + useWatchContractEvent({ + address: tokens, + abi: Abis.tip20, + eventName: 'Transfer', + args: { from: address }, + enabled: !!address, + onLogs, + }) + } + export function BalancesFooter(props: { address?: string | undefined; tokens: Address[] }) { const { address, tokens } = props const personalBalanceLabel = tokens.length > 1 ? 'Personal balances' : 'Personal balance' + useWatchBalanceTransfers(address as Address | undefined, tokens) return (
@@ -377,7 +391,7 @@ export function Step( {error && ( <>
-
+
{'shortMessage' in error ? error.shortMessage : error.message}