Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 2 additions & 2 deletions e2e/demo-success.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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(
'<div data-active data-completed><header><button class="bg-destructiveTint">Sign out</button></header><button>Add more funds</button></div>',
'<div data-active data-completed><div><p role="alert">Enter a valid address</p></div><button>Add more funds</button></div>',
)
await expectDemoSuccess(page, page.getByRole('button', { name: 'Add more funds' }))
})
28 changes: 16 additions & 12 deletions e2e/faucet.test.ts
Original file line number Diff line number Diff line change
@@ -1,3 +1,4 @@
import { expectDemoSuccess, getDemoStep } from './helpers'
import { expect, test } from './live'

test('fund an address via faucet', async ({ page }) => {
Expand All @@ -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',
Expand Down Expand Up @@ -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()
Expand All @@ -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(() => {})
}
Expand Down
1 change: 1 addition & 0 deletions e2e/helpers.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 })
Expand Down
120 changes: 120 additions & 0 deletions src/components/guides/Demo.test.tsx
Original file line number Diff line number Diff line change
@@ -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<string, Address>
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(<Container.BalancesFooter address={account} tokens={tokens} />)

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<typeof import('@tanstack/react-query')>('@tanstack/react-query')
const balanceKey = (token: Address) =>
Actions.token.getBalance.queryKey({ account, token, chainId: tempoModerato.id })
renderToStaticMarkup(<Container.BalancesFooter address={account} tokens={tokens} />)

// 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(<Container.BalancesFooter tokens={tokens} />)

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(
<Step
active
completed={false}
error={new Error('Request exceeds defined limit.')}
number={2}
title="Add testnet funds to your wallet."
/>,
)

expect(html).toMatch(/<div role="alert"[^>]*>Request exceeds defined limit\.<\/div><\/div>$/)
})
83 changes: 50 additions & 33 deletions src/components/guides/Demo.tsx
Original file line number Diff line number Diff line change
@@ -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'
Expand Down Expand Up @@ -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 (
<div>
Expand All @@ -260,16 +247,46 @@ export namespace Container {
) : (
<span {...ui.balancesFooterItemText()}>
<span {...ui.balancesFooterItemText2()}>{balance.formatted}</span>
{metadata.symbol}
{symbol}
</span>
)}
</div>
)
}

// 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 (
<div {...ui.balancesFooterLayout()}>
Expand Down
Loading