Skip to content

Commit 82f2f21

Browse files
test: add component test coverage (Tier 4)
New tests for shared components and utilities: - TokenLogo: img src/alt/size, IPFS URL conversion, placeholder on error - TransactionButton: wallet states, pending label, onMined callback - SwitchNetwork: network display, disabled state, menu item rendering - WalletStatusVerifier: connect fallback, wrong chain, synced renders children - withWalletStatusVerifier HOC: fallback and pass-through behavior - withSuspense/withSuspenseAndRetry: Suspense fallback, error message, retry Also adds: - ResizeObserver mock to setupTests.ts (required by @floating-ui in jsdom) - .env.test and src/test-utils.tsx (shared test utilities from tier 1)
1 parent 1fcbe2f commit 82f2f21

6 files changed

Lines changed: 576 additions & 1 deletion

File tree

setupTests.ts

Lines changed: 8 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,9 +1,16 @@
11
import * as matchers from '@testing-library/jest-dom/matchers'
22
import { cleanup } from '@testing-library/react'
3-
import { afterEach, expect } from 'vitest'
3+
import { afterEach, expect, vi } from 'vitest'
44

55
expect.extend(matchers)
66

7+
// ResizeObserver is not implemented in jsdom but required by @floating-ui (Chakra menus/popovers)
8+
global.ResizeObserver = vi.fn().mockImplementation(() => ({
9+
observe: vi.fn(),
10+
unobserve: vi.fn(),
11+
disconnect: vi.fn(),
12+
}))
13+
714
afterEach(() => {
815
cleanup()
916
})
Lines changed: 115 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,115 @@
1+
import { ChakraProvider, createSystem, defaultConfig } from '@chakra-ui/react'
2+
import { fireEvent, render, screen, waitFor } from '@testing-library/react'
3+
import { describe, expect, it, vi } from 'vitest'
4+
import SwitchNetwork, { type Networks } from './SwitchNetwork'
5+
6+
const system = createSystem(defaultConfig)
7+
8+
vi.mock('@/src/hooks/useWeb3Status', () => ({
9+
useWeb3Status: vi.fn(),
10+
}))
11+
12+
vi.mock('wagmi', () => ({
13+
useSwitchChain: vi.fn(),
14+
}))
15+
16+
import * as useWeb3StatusModule from '@/src/hooks/useWeb3Status'
17+
import * as wagmiModule from 'wagmi'
18+
19+
const mockNetworks: Networks = [
20+
{ id: 1, label: 'Ethereum', icon: <span>ETH</span> },
21+
{ id: 137, label: 'Polygon', icon: <span>MATIC</span> },
22+
]
23+
24+
function defaultWeb3Status(overrides = {}) {
25+
return {
26+
isWalletConnected: true,
27+
walletChainId: undefined as number | undefined,
28+
walletClient: undefined,
29+
...overrides,
30+
}
31+
}
32+
33+
function defaultSwitchChain() {
34+
return {
35+
chains: [
36+
{ id: 1, name: 'Ethereum' },
37+
{ id: 137, name: 'Polygon' },
38+
],
39+
switchChain: vi.fn(),
40+
}
41+
}
42+
43+
function renderSwitchNetwork(networks = mockNetworks) {
44+
return render(
45+
<ChakraProvider value={system}>
46+
<SwitchNetwork networks={networks} />
47+
</ChakraProvider>,
48+
)
49+
}
50+
51+
describe('SwitchNetwork', () => {
52+
it('shows "Select a network" when wallet chain does not match any network', () => {
53+
vi.mocked(useWeb3StatusModule.useWeb3Status).mockReturnValue(
54+
// biome-ignore lint/suspicious/noExplicitAny: partial mock
55+
defaultWeb3Status({ walletChainId: 999 }) as any,
56+
)
57+
vi.mocked(wagmiModule.useSwitchChain).mockReturnValue(
58+
// biome-ignore lint/suspicious/noExplicitAny: partial mock
59+
defaultSwitchChain() as any,
60+
)
61+
renderSwitchNetwork()
62+
expect(screen.getByText('Select a network')).toBeDefined()
63+
})
64+
65+
it('shows current network label when wallet is on a listed chain', async () => {
66+
vi.mocked(useWeb3StatusModule.useWeb3Status).mockReturnValue(
67+
// biome-ignore lint/suspicious/noExplicitAny: partial mock
68+
defaultWeb3Status({ walletChainId: 1 }) as any,
69+
)
70+
vi.mocked(wagmiModule.useSwitchChain).mockReturnValue(
71+
// biome-ignore lint/suspicious/noExplicitAny: partial mock
72+
defaultSwitchChain() as any,
73+
)
74+
renderSwitchNetwork()
75+
expect(screen.getByText('Ethereum')).toBeDefined()
76+
})
77+
78+
it('trigger button is disabled when wallet not connected', () => {
79+
vi.mocked(useWeb3StatusModule.useWeb3Status).mockReturnValue(
80+
// biome-ignore lint/suspicious/noExplicitAny: partial mock
81+
defaultWeb3Status({ isWalletConnected: false }) as any,
82+
)
83+
vi.mocked(wagmiModule.useSwitchChain).mockReturnValue(
84+
// biome-ignore lint/suspicious/noExplicitAny: partial mock
85+
defaultSwitchChain() as any,
86+
)
87+
renderSwitchNetwork()
88+
const button = screen.getByRole('button')
89+
expect(button).toBeDefined()
90+
expect(button.hasAttribute('disabled') || button.getAttribute('data-disabled') !== null).toBe(
91+
true,
92+
)
93+
})
94+
95+
it('shows all network options in the menu after opening it', async () => {
96+
vi.mocked(useWeb3StatusModule.useWeb3Status).mockReturnValue(
97+
// biome-ignore lint/suspicious/noExplicitAny: partial mock
98+
defaultWeb3Status({ isWalletConnected: true }) as any,
99+
)
100+
vi.mocked(wagmiModule.useSwitchChain).mockReturnValue(
101+
// biome-ignore lint/suspicious/noExplicitAny: partial mock
102+
defaultSwitchChain() as any,
103+
)
104+
renderSwitchNetwork()
105+
106+
// Open the menu by clicking the trigger
107+
const trigger = screen.getByRole('button')
108+
fireEvent.click(trigger)
109+
110+
await waitFor(() => {
111+
expect(screen.getByText('Ethereum')).toBeDefined()
112+
expect(screen.getByText('Polygon')).toBeDefined()
113+
})
114+
})
115+
})
Lines changed: 75 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,75 @@
1+
import type { Token } from '@/src/types/token'
2+
import { ChakraProvider, createSystem, defaultConfig } from '@chakra-ui/react'
3+
import { fireEvent, render, screen } from '@testing-library/react'
4+
import { describe, expect, it } from 'vitest'
5+
import TokenLogo from './TokenLogo'
6+
7+
const system = createSystem(defaultConfig)
8+
9+
const mockToken: Token = {
10+
address: '0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48',
11+
chainId: 1,
12+
decimals: 6,
13+
name: 'USD Coin',
14+
symbol: 'USDC',
15+
logoURI: 'https://example.com/usdc.png',
16+
}
17+
18+
const tokenWithoutLogo: Token = {
19+
...mockToken,
20+
logoURI: undefined,
21+
}
22+
23+
function renderTokenLogo(token: Token, size?: number) {
24+
return render(
25+
<ChakraProvider value={system}>
26+
<TokenLogo
27+
token={token}
28+
size={size}
29+
/>
30+
</ChakraProvider>,
31+
)
32+
}
33+
34+
describe('TokenLogo', () => {
35+
it('renders an img with correct src when logoURI is present', () => {
36+
renderTokenLogo(mockToken)
37+
const img = screen.getByRole('img')
38+
expect(img).toBeDefined()
39+
expect(img.getAttribute('src')).toBe(mockToken.logoURI)
40+
})
41+
42+
it('renders an img with correct alt text', () => {
43+
renderTokenLogo(mockToken)
44+
const img = screen.getByAltText('USD Coin')
45+
expect(img).toBeDefined()
46+
})
47+
48+
it('applies correct width and height from size prop', () => {
49+
renderTokenLogo(mockToken, 48)
50+
const img = screen.getByRole('img')
51+
expect(img.getAttribute('width')).toBe('48')
52+
expect(img.getAttribute('height')).toBe('48')
53+
})
54+
55+
it('renders placeholder with token symbol initial when no logoURI', () => {
56+
renderTokenLogo(tokenWithoutLogo)
57+
expect(screen.queryByRole('img')).toBeNull()
58+
expect(screen.getByText('U')).toBeDefined() // first char of 'USDC'
59+
})
60+
61+
it('renders placeholder when img fails to load', () => {
62+
renderTokenLogo(mockToken)
63+
const img = screen.getByRole('img')
64+
fireEvent.error(img)
65+
expect(screen.queryByRole('img')).toBeNull()
66+
expect(screen.getByText('U')).toBeDefined()
67+
})
68+
69+
it('converts ipfs:// URLs to https://ipfs.io gateway URLs', () => {
70+
const ipfsToken: Token = { ...mockToken, logoURI: 'ipfs://QmHash123' }
71+
renderTokenLogo(ipfsToken)
72+
const img = screen.getByRole('img')
73+
expect(img.getAttribute('src')).toBe('https://ipfs.io/ipfs/QmHash123')
74+
})
75+
})
Lines changed: 142 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,142 @@
1+
import { ChakraProvider, createSystem, defaultConfig } from '@chakra-ui/react'
2+
import { fireEvent, render, screen, waitFor } from '@testing-library/react'
3+
import { describe, expect, it, vi } from 'vitest'
4+
import TransactionButton from './TransactionButton'
5+
6+
const system = createSystem(defaultConfig)
7+
8+
vi.mock('@/src/hooks/useWeb3Status', () => ({
9+
useWeb3Status: vi.fn(),
10+
}))
11+
12+
vi.mock('@/src/providers/TransactionNotificationProvider', () => ({
13+
useTransactionNotification: vi.fn(() => ({
14+
watchTx: vi.fn(),
15+
watchHash: vi.fn(),
16+
watchSignature: vi.fn(),
17+
})),
18+
}))
19+
20+
vi.mock('wagmi', () => ({
21+
useWaitForTransactionReceipt: vi.fn(() => ({ data: undefined })),
22+
}))
23+
24+
vi.mock('@/src/providers/Web3Provider', () => ({
25+
ConnectWalletButton: () => <button type="button">Connect Wallet</button>,
26+
}))
27+
28+
import * as useWeb3StatusModule from '@/src/hooks/useWeb3Status'
29+
import * as wagmiModule from 'wagmi'
30+
31+
// chains[0] = optimismSepolia (id: 11155420) when PUBLIC_INCLUDE_TESTNETS=true (default)
32+
const OP_SEPOLIA_ID = 11155420 as const
33+
34+
function connectedStatus() {
35+
return {
36+
isWalletConnected: true,
37+
isWalletSynced: true,
38+
walletChainId: OP_SEPOLIA_ID,
39+
appChainId: OP_SEPOLIA_ID,
40+
address: '0x1234567890abcdef1234567890abcdef12345678' as `0x${string}`,
41+
balance: undefined,
42+
connectingWallet: false,
43+
switchingChain: false,
44+
walletClient: undefined,
45+
readOnlyClient: undefined,
46+
switchChain: vi.fn(),
47+
disconnect: vi.fn(),
48+
}
49+
}
50+
51+
// biome-ignore lint/suspicious/noExplicitAny: test helper accepts flexible props
52+
function renderButton(props: any = {}) {
53+
return render(
54+
<ChakraProvider value={system}>
55+
<TransactionButton
56+
transaction={() => Promise.resolve('0x1' as `0x${string}`)}
57+
{...props}
58+
/>
59+
</ChakraProvider>,
60+
)
61+
}
62+
63+
describe('TransactionButton', () => {
64+
it('renders fallback when wallet not connected', () => {
65+
vi.mocked(useWeb3StatusModule.useWeb3Status).mockReturnValue({
66+
...connectedStatus(),
67+
isWalletConnected: false,
68+
isWalletSynced: false,
69+
})
70+
renderButton()
71+
expect(screen.getByText('Connect Wallet')).toBeDefined()
72+
})
73+
74+
it('renders switch chain button when wallet is on wrong chain', () => {
75+
vi.mocked(useWeb3StatusModule.useWeb3Status).mockReturnValue({
76+
...connectedStatus(),
77+
isWalletSynced: false,
78+
walletChainId: 1,
79+
})
80+
renderButton()
81+
expect(screen.getByRole('button').textContent?.toLowerCase()).toContain('switch to')
82+
})
83+
84+
it('renders with default label when wallet is connected and synced', () => {
85+
vi.mocked(useWeb3StatusModule.useWeb3Status).mockReturnValue(connectedStatus())
86+
vi.mocked(wagmiModule.useWaitForTransactionReceipt).mockReturnValue({
87+
data: undefined,
88+
} as ReturnType<typeof wagmiModule.useWaitForTransactionReceipt>)
89+
renderButton()
90+
expect(screen.getByText('Send Transaction')).toBeDefined()
91+
})
92+
93+
it('renders with custom children label', () => {
94+
vi.mocked(useWeb3StatusModule.useWeb3Status).mockReturnValue(connectedStatus())
95+
vi.mocked(wagmiModule.useWaitForTransactionReceipt).mockReturnValue({
96+
data: undefined,
97+
} as ReturnType<typeof wagmiModule.useWaitForTransactionReceipt>)
98+
renderButton({ children: 'Deposit ETH' })
99+
expect(screen.getByText('Deposit ETH')).toBeDefined()
100+
})
101+
102+
it('shows labelSending while transaction is pending', async () => {
103+
vi.mocked(useWeb3StatusModule.useWeb3Status).mockReturnValue(connectedStatus())
104+
vi.mocked(wagmiModule.useWaitForTransactionReceipt).mockReturnValue({
105+
data: undefined,
106+
} as ReturnType<typeof wagmiModule.useWaitForTransactionReceipt>)
107+
108+
const neverResolves = () => new Promise<`0x${string}`>(() => {})
109+
renderButton({ transaction: neverResolves, labelSending: 'Processing...' })
110+
111+
expect(screen.getByRole('button').textContent).not.toContain('Processing...')
112+
113+
fireEvent.click(screen.getByRole('button'))
114+
115+
await waitFor(() => {
116+
expect(screen.getByRole('button').textContent).toContain('Processing...')
117+
})
118+
})
119+
120+
it('calls onMined when receipt becomes available', async () => {
121+
// biome-ignore lint/suspicious/noExplicitAny: mock receipt shape
122+
const mockReceipt = { status: 'success', transactionHash: '0x1' } as any
123+
const onMined = vi.fn()
124+
125+
vi.mocked(useWeb3StatusModule.useWeb3Status).mockReturnValue(connectedStatus())
126+
// Return receipt immediately so effect fires once isPending=true
127+
vi.mocked(wagmiModule.useWaitForTransactionReceipt).mockReturnValue({
128+
data: mockReceipt,
129+
} as ReturnType<typeof wagmiModule.useWaitForTransactionReceipt>)
130+
131+
renderButton({
132+
transaction: () => Promise.resolve('0x1' as `0x${string}`),
133+
onMined,
134+
})
135+
136+
fireEvent.click(screen.getByRole('button'))
137+
138+
await waitFor(() => {
139+
expect(onMined).toHaveBeenCalledWith(mockReceipt)
140+
})
141+
})
142+
})

0 commit comments

Comments
 (0)