diff --git a/e2e/demo-success.test.ts b/e2e/demo-success.test.ts index 7f3b30f7c..51226aa1d 100644 --- a/e2e/demo-success.test.ts +++ b/e2e/demo-success.test.ts @@ -32,9 +32,9 @@ test('ignores hidden error content when the demo succeeds', async ({ page }) => 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 3c2ca3e44..133935af0 100644 --- a/e2e/faucet.test.ts +++ b/e2e/faucet.test.ts @@ -1,3 +1,4 @@ +import { expectDemoSuccess, getDemoStep } from './helpers' import { expect, test } from './live' test('fund an address via faucet', async ({ page }) => { @@ -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 f1892eb85..c17d0e6d8 100644 --- a/e2e/helpers.ts +++ b/e2e/helpers.ts @@ -7,6 +7,7 @@ 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] > [role="alert"]') .filter({ visible: true }) 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 b00d8267f..7e5865ea2 100644 --- a/src/components/guides/Demo.tsx +++ b/src/components/guides/Demo.tsx @@ -1,8 +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,41 +226,19 @@ export function Container( export namespace Container { function BalancesFooterItem(props: { address: Address; token: Address }) { const { address, token } = props - const { - data: balance, - isPending: balanceIsPending, - refetch: balanceRefetch, - } = Hooks.token.useGetBalance({ + const { data: balance, isPending: balanceIsPending } = Hooks.token.useGetBalance({ account: address, token, }) - const { data: metadata, isPending: metadataIsPending } = Hooks.token.useGetMetadata({ - token, - }) - const onTransfer = React.useCallback(() => { - void balanceRefetch() - }, [balanceRefetch]) - - Hooks.token.useWatchTransfer({ - token, - args: { - to: address, - }, - onTransfer, - enabled: !!address, - }) - - Hooks.token.useWatchTransfer({ - token, - args: { - from: address, - }, - onTransfer, - 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 (
@@ -260,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 (