diff --git a/setupTests.ts b/setupTests.ts index a55b20e6..a9d13f77 100644 --- a/setupTests.ts +++ b/setupTests.ts @@ -4,6 +4,20 @@ import { afterEach, expect } from 'vitest' expect.extend(matchers) +// ResizeObserver is not implemented in jsdom but required by @floating-ui (Chakra menus/popovers). +// Use a real class rather than vi.fn() so vi.restoreAllMocks() in test files cannot clear it. +if (typeof globalThis.ResizeObserver === 'undefined') { + class ResizeObserver { + // biome-ignore lint/suspicious/noExplicitAny: stub for jsdom test environment + observe(_target: any) {} + // biome-ignore lint/suspicious/noExplicitAny: stub for jsdom test environment + unobserve(_target: any) {} + disconnect() {} + } + // @ts-expect-error ResizeObserver is not in the Node/jsdom type definitions + globalThis.ResizeObserver = ResizeObserver +} + afterEach(() => { cleanup() }) diff --git a/src/components/sharedComponents/SwitchNetwork.test.tsx b/src/components/sharedComponents/SwitchNetwork.test.tsx new file mode 100644 index 00000000..edbe1408 --- /dev/null +++ b/src/components/sharedComponents/SwitchNetwork.test.tsx @@ -0,0 +1,115 @@ +import { ChakraProvider, createSystem, defaultConfig } from '@chakra-ui/react' +import { fireEvent, render, screen, waitFor } from '@testing-library/react' +import { describe, expect, it, vi } from 'vitest' +import SwitchNetwork, { type Networks } from './SwitchNetwork' + +const system = createSystem(defaultConfig) + +vi.mock('@/src/hooks/useWeb3Status', () => ({ + useWeb3Status: vi.fn(), +})) + +vi.mock('wagmi', () => ({ + useSwitchChain: vi.fn(), +})) + +import * as useWeb3StatusModule from '@/src/hooks/useWeb3Status' +import * as wagmiModule from 'wagmi' + +const mockNetworks: Networks = [ + { id: 1, label: 'Ethereum', icon: ETH }, + { id: 137, label: 'Polygon', icon: MATIC }, +] + +function defaultWeb3Status(overrides = {}) { + return { + isWalletConnected: true, + walletChainId: undefined as number | undefined, + walletClient: undefined, + ...overrides, + } +} + +function defaultSwitchChain() { + return { + chains: [ + { id: 1, name: 'Ethereum' }, + { id: 137, name: 'Polygon' }, + ], + switchChain: vi.fn(), + } +} + +function renderSwitchNetwork(networks = mockNetworks) { + return render( + + + , + ) +} + +describe('SwitchNetwork', () => { + it('shows "Select a network" when wallet chain does not match any network', () => { + vi.mocked(useWeb3StatusModule.useWeb3Status).mockReturnValue( + // biome-ignore lint/suspicious/noExplicitAny: partial mock + defaultWeb3Status({ walletChainId: 999 }) as any, + ) + vi.mocked(wagmiModule.useSwitchChain).mockReturnValue( + // biome-ignore lint/suspicious/noExplicitAny: partial mock + defaultSwitchChain() as any, + ) + renderSwitchNetwork() + expect(screen.getByText('Select a network')).toBeDefined() + }) + + it('shows current network label when wallet is on a listed chain', async () => { + vi.mocked(useWeb3StatusModule.useWeb3Status).mockReturnValue( + // biome-ignore lint/suspicious/noExplicitAny: partial mock + defaultWeb3Status({ walletChainId: 1 }) as any, + ) + vi.mocked(wagmiModule.useSwitchChain).mockReturnValue( + // biome-ignore lint/suspicious/noExplicitAny: partial mock + defaultSwitchChain() as any, + ) + renderSwitchNetwork() + expect(screen.getByText('Ethereum')).toBeDefined() + }) + + it('trigger button is disabled when wallet not connected', () => { + vi.mocked(useWeb3StatusModule.useWeb3Status).mockReturnValue( + // biome-ignore lint/suspicious/noExplicitAny: partial mock + defaultWeb3Status({ isWalletConnected: false }) as any, + ) + vi.mocked(wagmiModule.useSwitchChain).mockReturnValue( + // biome-ignore lint/suspicious/noExplicitAny: partial mock + defaultSwitchChain() as any, + ) + renderSwitchNetwork() + const button = screen.getByRole('button') + expect(button).toBeDefined() + expect(button.hasAttribute('disabled') || button.getAttribute('data-disabled') !== null).toBe( + true, + ) + }) + + it('shows all network options in the menu after opening it', async () => { + vi.mocked(useWeb3StatusModule.useWeb3Status).mockReturnValue( + // biome-ignore lint/suspicious/noExplicitAny: partial mock + defaultWeb3Status({ isWalletConnected: true }) as any, + ) + vi.mocked(wagmiModule.useSwitchChain).mockReturnValue( + // biome-ignore lint/suspicious/noExplicitAny: partial mock + defaultSwitchChain() as any, + ) + renderSwitchNetwork() + + // Open the menu by clicking the trigger + const trigger = screen.getByRole('button') + fireEvent.click(trigger) + + await waitFor(() => { + expect(screen.getByText('Ethereum')).toBeDefined() + expect(screen.getByText('Polygon')).toBeDefined() + }) + }) +}) diff --git a/src/components/sharedComponents/TokenLogo.test.tsx b/src/components/sharedComponents/TokenLogo.test.tsx new file mode 100644 index 00000000..b71166ed --- /dev/null +++ b/src/components/sharedComponents/TokenLogo.test.tsx @@ -0,0 +1,75 @@ +import type { Token } from '@/src/types/token' +import { ChakraProvider, createSystem, defaultConfig } from '@chakra-ui/react' +import { fireEvent, render, screen } from '@testing-library/react' +import { describe, expect, it } from 'vitest' +import TokenLogo from './TokenLogo' + +const system = createSystem(defaultConfig) + +const mockToken: Token = { + address: '0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48', + chainId: 1, + decimals: 6, + name: 'USD Coin', + symbol: 'USDC', + logoURI: 'https://example.com/usdc.png', +} + +const tokenWithoutLogo: Token = { + ...mockToken, + logoURI: undefined, +} + +function renderTokenLogo(token: Token, size?: number) { + return render( + + + , + ) +} + +describe('TokenLogo', () => { + it('renders an img with correct src when logoURI is present', () => { + renderTokenLogo(mockToken) + const img = screen.getByRole('img') + expect(img).toBeDefined() + expect(img.getAttribute('src')).toBe(mockToken.logoURI) + }) + + it('renders an img with correct alt text', () => { + renderTokenLogo(mockToken) + const img = screen.getByAltText('USD Coin') + expect(img).toBeDefined() + }) + + it('applies correct width and height from size prop', () => { + renderTokenLogo(mockToken, 48) + const img = screen.getByRole('img') + expect(img.getAttribute('width')).toBe('48') + expect(img.getAttribute('height')).toBe('48') + }) + + it('renders placeholder with token symbol initial when no logoURI', () => { + renderTokenLogo(tokenWithoutLogo) + expect(screen.queryByRole('img')).toBeNull() + expect(screen.getByText('U')).toBeDefined() // first char of 'USDC' + }) + + it('renders placeholder when img fails to load', () => { + renderTokenLogo(mockToken) + const img = screen.getByRole('img') + fireEvent.error(img) + expect(screen.queryByRole('img')).toBeNull() + expect(screen.getByText('U')).toBeDefined() + }) + + it('converts ipfs:// URLs to https://ipfs.io gateway URLs', () => { + const ipfsToken: Token = { ...mockToken, logoURI: 'ipfs://QmHash123' } + renderTokenLogo(ipfsToken) + const img = screen.getByRole('img') + expect(img.getAttribute('src')).toBe('https://ipfs.io/ipfs/QmHash123') + }) +}) diff --git a/src/components/sharedComponents/TransactionButton.test.tsx b/src/components/sharedComponents/TransactionButton.test.tsx new file mode 100644 index 00000000..74e4686d --- /dev/null +++ b/src/components/sharedComponents/TransactionButton.test.tsx @@ -0,0 +1,146 @@ +import { ChakraProvider, createSystem, defaultConfig } from '@chakra-ui/react' +import { fireEvent, render, screen, waitFor } from '@testing-library/react' +import { describe, expect, it, vi } from 'vitest' +import TransactionButton from './TransactionButton' + +const system = createSystem(defaultConfig) + +vi.mock('@/src/hooks/useWeb3Status', () => ({ + useWeb3Status: vi.fn(), +})) + +vi.mock('@/src/providers/TransactionNotificationProvider', () => ({ + useTransactionNotification: vi.fn(() => ({ + watchTx: vi.fn(), + watchHash: vi.fn(), + watchSignature: vi.fn(), + })), +})) + +vi.mock('wagmi', () => ({ + useWaitForTransactionReceipt: vi.fn(() => ({ data: undefined })), +})) + +vi.mock('@/src/providers/Web3Provider', () => ({ + ConnectWalletButton: () => , +})) + +import * as useWeb3StatusModule from '@/src/hooks/useWeb3Status' +import * as wagmiModule from 'wagmi' + +// chains[0] = optimismSepolia (id: 11155420) when PUBLIC_INCLUDE_TESTNETS=true (default) +const OP_SEPOLIA_ID = 11155420 as const + +function connectedStatus() { + return { + isWalletConnected: true, + isWalletSynced: true, + walletChainId: OP_SEPOLIA_ID, + appChainId: OP_SEPOLIA_ID, + address: '0x1234567890abcdef1234567890abcdef12345678' as `0x${string}`, + balance: undefined, + connectingWallet: false, + switchingChain: false, + walletClient: undefined, + readOnlyClient: undefined, + switchChain: vi.fn(), + disconnect: vi.fn(), + } +} + +// biome-ignore lint/suspicious/noExplicitAny: test helper accepts flexible props +function renderButton(props: any = {}) { + return render( + + Promise.resolve('0x1' as `0x${string}`)} + {...props} + /> + , + ) +} + +describe('TransactionButton', () => { + it('renders fallback when wallet not connected', () => { + vi.mocked(useWeb3StatusModule.useWeb3Status).mockReturnValue({ + ...connectedStatus(), + isWalletConnected: false, + isWalletSynced: false, + }) + renderButton() + expect(screen.getByText('Connect Wallet')).toBeDefined() + }) + + it('renders switch chain button when wallet is on wrong chain', () => { + vi.mocked(useWeb3StatusModule.useWeb3Status).mockReturnValue({ + ...connectedStatus(), + isWalletSynced: false, + walletChainId: 1, + }) + renderButton() + expect(screen.getByRole('button').textContent?.toLowerCase()).toContain('switch to') + }) + + it('renders with default label when wallet is connected and synced', () => { + vi.mocked(useWeb3StatusModule.useWeb3Status).mockReturnValue(connectedStatus()) + vi.mocked(wagmiModule.useWaitForTransactionReceipt).mockReturnValue({ + data: undefined, + } as ReturnType) + renderButton() + expect(screen.getByText('Send Transaction')).toBeDefined() + }) + + it('renders with custom children label', () => { + vi.mocked(useWeb3StatusModule.useWeb3Status).mockReturnValue(connectedStatus()) + vi.mocked(wagmiModule.useWaitForTransactionReceipt).mockReturnValue({ + data: undefined, + } as ReturnType) + renderButton({ children: 'Deposit ETH' }) + expect(screen.getByText('Deposit ETH')).toBeDefined() + }) + + it('shows labelSending while transaction is pending', async () => { + vi.mocked(useWeb3StatusModule.useWeb3Status).mockReturnValue(connectedStatus()) + vi.mocked(wagmiModule.useWaitForTransactionReceipt).mockReturnValue({ + data: undefined, + } as ReturnType) + + const neverResolves = () => new Promise<`0x${string}`>(() => {}) + renderButton({ transaction: neverResolves, labelSending: 'Processing...' }) + + expect(screen.getByRole('button').textContent).not.toContain('Processing...') + + fireEvent.click(screen.getByRole('button')) + + await waitFor(() => { + expect(screen.getByRole('button').textContent).toContain('Processing...') + }) + }) + + it('calls onMined when receipt becomes available', async () => { + // biome-ignore lint/suspicious/noExplicitAny: mock receipt shape + const mockReceipt = { status: 'success', transactionHash: '0x1' } as any + const onMined = vi.fn() + + vi.mocked(useWeb3StatusModule.useWeb3Status).mockReturnValue(connectedStatus()) + // Only return a receipt when called with the matching hash so the mock + // doesn't fire prematurely before the transaction is submitted. + vi.mocked(wagmiModule.useWaitForTransactionReceipt).mockImplementation( + (config) => + ({ + data: config?.hash === '0x1' ? mockReceipt : undefined, + }) as ReturnType, + ) + + renderButton({ + transaction: () => Promise.resolve('0x1' as `0x${string}`), + onMined, + }) + + fireEvent.click(screen.getByRole('button')) + + await waitFor(() => { + expect(onMined).toHaveBeenCalledWith(mockReceipt) + }) + }) +}) diff --git a/src/components/sharedComponents/WalletStatusVerifier.test.tsx b/src/components/sharedComponents/WalletStatusVerifier.test.tsx new file mode 100644 index 00000000..bd7d75ed --- /dev/null +++ b/src/components/sharedComponents/WalletStatusVerifier.test.tsx @@ -0,0 +1,121 @@ +import { ChakraProvider, createSystem, defaultConfig } from '@chakra-ui/react' +import { render, screen } from '@testing-library/react' +import { describe, expect, it, vi } from 'vitest' +import { WalletStatusVerifier, withWalletStatusVerifier } from './WalletStatusVerifier' + +const system = createSystem(defaultConfig) + +vi.mock('@/src/hooks/useWeb3Status', () => ({ + useWeb3Status: vi.fn(), +})) + +vi.mock('@/src/providers/Web3Provider', () => ({ + ConnectWalletButton: () => , +})) + +import * as useWeb3StatusModule from '@/src/hooks/useWeb3Status' + +// chains[0] = optimismSepolia (id: 11155420) when PUBLIC_INCLUDE_TESTNETS=true (default) +const OP_SEPOLIA_ID = 11155420 as const + +function connectedSyncedStatus(overrides = {}) { + return { + isWalletConnected: true, + isWalletSynced: true, + walletChainId: OP_SEPOLIA_ID, + appChainId: OP_SEPOLIA_ID, + switchChain: vi.fn(), + disconnect: vi.fn(), + address: '0x1234567890abcdef1234567890abcdef12345678' as `0x${string}`, + balance: undefined, + connectingWallet: false, + switchingChain: false, + walletClient: undefined, + readOnlyClient: undefined, + ...overrides, + } +} + +function wrap(ui: React.ReactElement) { + return render({ui}) +} + +describe('WalletStatusVerifier', () => { + it('renders default ConnectWalletButton fallback when wallet not connected', () => { + vi.mocked(useWeb3StatusModule.useWeb3Status).mockReturnValue( + // biome-ignore lint/suspicious/noExplicitAny: partial mock + connectedSyncedStatus({ isWalletConnected: false, isWalletSynced: false }) as any, + ) + wrap( + +
Protected Content
+
, + ) + expect(screen.getByText('Connect Wallet')).toBeDefined() + expect(screen.queryByText('Protected Content')).toBeNull() + }) + + it('renders custom fallback when wallet not connected', () => { + vi.mocked(useWeb3StatusModule.useWeb3Status).mockReturnValue( + // biome-ignore lint/suspicious/noExplicitAny: partial mock + connectedSyncedStatus({ isWalletConnected: false, isWalletSynced: false }) as any, + ) + wrap( + Custom Fallback}> +
Protected Content
+
, + ) + expect(screen.getByText('Custom Fallback')).toBeDefined() + }) + + it('renders switch chain button when wallet is on wrong chain', () => { + vi.mocked(useWeb3StatusModule.useWeb3Status).mockReturnValue( + // biome-ignore lint/suspicious/noExplicitAny: partial mock + connectedSyncedStatus({ isWalletSynced: false, walletChainId: 1 }) as any, + ) + wrap( + +
Protected Content
+
, + ) + expect(screen.getByRole('button').textContent?.toLowerCase()).toContain('switch to') + expect(screen.queryByText('Protected Content')).toBeNull() + }) + + it('renders children when wallet is connected and synced', () => { + vi.mocked(useWeb3StatusModule.useWeb3Status).mockReturnValue( + // biome-ignore lint/suspicious/noExplicitAny: partial mock + connectedSyncedStatus() as any, + ) + wrap( + +
Protected Content
+
, + ) + expect(screen.getByText('Protected Content')).toBeDefined() + }) +}) + +describe('withWalletStatusVerifier HOC', () => { + const ProtectedComponent = () =>
Protected Component
+ const Wrapped = withWalletStatusVerifier(ProtectedComponent) + + it('renders fallback when wallet not connected', () => { + vi.mocked(useWeb3StatusModule.useWeb3Status).mockReturnValue( + // biome-ignore lint/suspicious/noExplicitAny: partial mock + connectedSyncedStatus({ isWalletConnected: false, isWalletSynced: false }) as any, + ) + wrap() + expect(screen.getByText('Connect Wallet')).toBeDefined() + expect(screen.queryByText('Protected Component')).toBeNull() + }) + + it('renders wrapped component when wallet is connected and synced', () => { + vi.mocked(useWeb3StatusModule.useWeb3Status).mockReturnValue( + // biome-ignore lint/suspicious/noExplicitAny: partial mock + connectedSyncedStatus() as any, + ) + wrap() + expect(screen.getByText('Protected Component')).toBeDefined() + }) +}) diff --git a/src/utils/suspenseWrapper.test.tsx b/src/utils/suspenseWrapper.test.tsx new file mode 100644 index 00000000..bd529c57 --- /dev/null +++ b/src/utils/suspenseWrapper.test.tsx @@ -0,0 +1,118 @@ +import { ChakraProvider, createSystem, defaultConfig } from '@chakra-ui/react' +import { QueryClient, QueryClientProvider } from '@tanstack/react-query' +import { fireEvent, render, screen, waitFor } from '@testing-library/react' +import type { ReactNode } from 'react' +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' +import { withSuspense, withSuspenseAndRetry } from './suspenseWrapper' + +const system = createSystem(defaultConfig) + +// Silence expected React error boundary console.errors. +// Only restore this specific spy — vi.restoreAllMocks() would also wipe global +// polyfills set up in setupTests.ts (e.g. ResizeObserver). +let consoleErrorSpy: ReturnType +beforeEach(() => { + consoleErrorSpy = vi.spyOn(console, 'error').mockImplementation(() => {}) +}) +afterEach(() => { + consoleErrorSpy.mockRestore() +}) + +function wrap(ui: ReactNode, withQuery = false) { + const queryClient = new QueryClient({ defaultOptions: { queries: { retry: false } } }) + const inner = withQuery ? ( + {ui} + ) : ( + ui + ) + return render({inner}) +} + +const NormalComponent = () =>
Normal Content
+const SuspendedComponent = () => { + // eslint-disable-next-line @typescript-eslint/no-throw-literal + throw new Promise(() => {}) +} + +function makeErrorComponent(message: string) { + return function ErrorComponent() { + throw new Error(message) + } +} + +describe('withSuspense', () => { + it('renders the wrapped component when no error or suspension', () => { + const Wrapped = withSuspense(NormalComponent) + wrap() + expect(screen.getByText('Normal Content')).toBeDefined() + }) + + it('shows custom suspense fallback while component is suspended', () => { + const Wrapped = withSuspense(SuspendedComponent) + wrap(Loading...} />) + expect(screen.getByText('Loading...')).toBeDefined() + }) + + it('shows default error message when component throws', async () => { + const Wrapped = withSuspense(makeErrorComponent('boom')) + wrap() + await waitFor(() => { + expect(screen.getByText('Something went wrong...')).toBeDefined() + }) + }) + + it('shows custom errorFallback text when provided', async () => { + const Wrapped = withSuspense(makeErrorComponent('boom')) + wrap() + await waitFor(() => { + expect(screen.getByText('Custom error text')).toBeDefined() + }) + }) +}) + +describe('withSuspenseAndRetry', () => { + it('renders the wrapped component when no error or suspension', () => { + const Wrapped = withSuspenseAndRetry(NormalComponent) + wrap(, true) + expect(screen.getByText('Normal Content')).toBeDefined() + }) + + it('shows custom suspense fallback while component is suspended', () => { + const Wrapped = withSuspenseAndRetry(SuspendedComponent) + wrap(Loading...} />, true) + expect(screen.getByText('Loading...')).toBeDefined() + }) + + it('shows error message and Try Again button when component throws', async () => { + const Wrapped = withSuspenseAndRetry(makeErrorComponent('Fetch failed')) + wrap(, true) + await waitFor(() => { + expect(screen.getByText('Fetch failed')).toBeDefined() + expect(screen.getByText('Try Again')).toBeDefined() + }) + }) + + it('resets error boundary when Try Again is clicked', async () => { + // Use an external flag so React 19 retries also throw (React retries after first throw + // before giving up to the error boundary, which would reset renderCount-based approaches) + const state = { shouldThrow: true } + const RecoveryComponent = () => { + if (state.shouldThrow) throw new Error('Persistent error') + return
Recovered
+ } + + const Wrapped = withSuspenseAndRetry(RecoveryComponent) + wrap(, true) + + await waitFor(() => { + expect(screen.getByText('Persistent error')).toBeDefined() + }) + + state.shouldThrow = false + fireEvent.click(screen.getByText('Try Again')) + + await waitFor(() => { + expect(screen.getByText('Recovered')).toBeDefined() + }) + }) +})