import type { Token } from '@/token'; import { renderHook, waitFor } from '@testing-library/react'; import { act } from 'react'; import { type Chain, parseEther } from 'viem'; import { type Mock, beforeEach, describe, expect, it, vi } from 'vitest'; import { useAccount, useConfig } from 'wagmi'; import { getBalance, readContract } from 'wagmi/actions'; import { useChainConfig } from '../hooks/useAppchainConfig'; import { useDeposit } from '../hooks/useDeposit'; import { useWithdraw } from '../hooks/useWithdraw'; import type { Appchain, BridgeableToken } from '../types'; import { AppchainBridgeProvider, useAppchainBridgeContext, } from './AppchainBridgeProvider'; vi.mock('wagmi', () => ({ useAccount: vi.fn(), useConfig: vi.fn(), })); vi.mock('wagmi/actions', () => ({ getBalance: vi.fn(), readContract: vi.fn(), })); vi.mock('../hooks/useAppchainConfig', () => ({ useChainConfig: vi.fn(), })); vi.mock('../hooks/useDeposit', () => ({ useDeposit: vi.fn(), })); vi.mock('../hooks/useWithdraw', () => ({ useWithdraw: vi.fn(), })); vi.mock('../utils/getETHPrice', () => ({ getETHPrice: vi.fn().mockResolvedValue('2000'), })); const mockChain = { id: 84532, name: 'Base Sepolia', nativeCurrency: { name: 'Ethereum', symbol: 'ETH', decimals: 18, }, } as Chain; const mockAppchain = { chain: { id: 8453, name: 'Base', nativeCurrency: { name: 'Ethereum', symbol: 'ETH', decimals: 18, }, }, } as Appchain; const mockConfig = { chainId: 8453, contracts: { l2OutputOracle: '0x123', systemConfig: '0x456', optimismPortal: '0x789', }, }; const mockToken = { address: '', symbol: 'ETH', decimals: 18, chainId: 1, image: '', name: 'ETH', } as Token; const mockBridgeableTokens = [mockToken]; describe('AppchainBridgeProvider', () => { let result: { current: ReturnType; }; const renderBridgeProvider = async (props = {}) => { let hookResult!: { current: ReturnType; }; await act(async () => { const rendered = renderHook(() => useAppchainBridgeContext(), { wrapper: ({ children }) => ( {children} ), }); hookResult = rendered.result; }); return hookResult; }; beforeEach(async () => { vi.resetAllMocks(); vi.stubGlobal('open', vi.fn()); (useAccount as ReturnType).mockReturnValue({ address: '0x123', }); (useConfig as ReturnType).mockReturnValue({}); (useChainConfig as ReturnType).mockReturnValue({ config: mockConfig, error: null, }); (useDeposit as ReturnType).mockReturnValue({ deposit: vi.fn(), depositStatus: 'idle', resetDepositStatus: vi.fn(), }); (useWithdraw as ReturnType).mockReturnValue({ withdraw: vi.fn(), withdrawStatus: 'idle', resetWithdrawStatus: vi.fn(), waitForWithdrawal: vi.fn(), proveAndFinalizeWithdrawal: vi.fn(), }); (getBalance as ReturnType).mockResolvedValue({ value: parseEther('1'), decimals: 18, }); (readContract as ReturnType).mockResolvedValue( parseEther('1'), ); result = await renderBridgeProvider(); }); it('should throw error when used outside provider', () => { const consoleErrorSpy = vi .spyOn(console, 'error') .mockImplementation(() => {}); expect(() => { renderHook(() => useAppchainBridgeContext()); }).toThrow('useAppchainBridge must be used within a BridgeProvider'); consoleErrorSpy.mockRestore(); }); it('should initialize with correct default values', async () => { await waitFor(() => { expect(result.current.from.id).toBe(mockChain.id); expect(result.current.to.id).toBe(mockAppchain.chain.id); expect(result.current.bridgeParams.amount).toBe(''); expect(result.current.bridgeParams.amountUSD).toBe('0.00'); }); }); it('should update recipient when wallet connects', async () => { await waitFor(() => { expect(result.current.bridgeParams.recipient).toBe('0x123'); }); }); it('should handle network toggle correctly', async () => { const initialFromId = result.current.from.id; const initialToId = result.current.to.id; act(() => { result.current.handleToggle(); }); await waitFor(() => { expect(result.current.from.id).toBe(initialToId); expect(result.current.to.id).toBe(initialFromId); }); }); it('should handle amount changes with price updates', async () => { result = await renderBridgeProvider({ handleFetchPrice: () => Promise.resolve('2000.00'), }); await waitFor(async () => { result.current.handleAmountChange({ amount: '1', token: mockToken, }); }); await waitFor(() => { expect(result.current.bridgeParams.amount).toBe('1'); expect(result.current.bridgeParams.amountUSD).toBe('2000.00'); }); }); it('should handle withdraw modal state changes', async () => { await act(async () => { result.current.setIsWithdrawModalOpen(true); }); expect(result.current.isWithdrawModalOpen).toBe(true); await act(async () => { result.current.setIsWithdrawModalOpen(false); }); expect(result.current.isWithdrawModalOpen).toBe(false); }); it('should handle address modal state changes', async () => { await waitFor(async () => { result.current.setIsAddressModalOpen(true); expect(result.current.isAddressModalOpen).toBe(true); }); await waitFor(async () => { result.current.handleAddressSelect('0x456' as `0x${string}`); expect(result.current.bridgeParams.recipient).toBe('0x456'); }); }); it('should call deposit function when handleDeposit is called', async () => { const mockDeposit = vi.fn(); (useDeposit as Mock).mockReturnValue({ deposit: mockDeposit, depositStatus: 'idle', transactionHash: '', resetDepositStatus: vi.fn(), }); const result = await renderBridgeProvider(); await act(async () => { result.current.handleDeposit(); }); expect(mockDeposit).toHaveBeenCalled(); }); it('should set resumeWithdrawalTxHash when handleResumeTransaction is called', async () => { const result = await renderBridgeProvider(); await act(async () => { result.current.handleResumeTransaction( '0x1234567890123456789012345678901234567890123456789012345678901234', ); }); expect(result.current.resumeWithdrawalTxHash).toBe( '0x1234567890123456789012345678901234567890123456789012345678901234', ); expect(result.current.isResumeTransactionModalOpen).toBe(false); }); it('should validate required props', () => { const consoleErrorSpy = vi .spyOn(console, 'error') .mockImplementation(() => {}); // Suppress error logging expect(() => { renderHook(() => useAppchainBridgeContext(), { wrapper: ({ children }) => ( {children} ), }); }).toThrow( 'Bridgeable tokens must be provided as a parameter to AppchainBridge.', ); consoleErrorSpy.mockRestore(); // Restore console.error after test }); it('should throw error when chain config has error', () => { (useChainConfig as Mock).mockReturnValue({ config: null, error: new Error('Chain config error'), }); const consoleErrorSpy = vi .spyOn(console, 'error') .mockImplementation(() => {}); // Suppress error logging expect(() => { renderHook(() => useAppchainBridgeContext(), { wrapper: ({ children }) => ( {children} ), }); }).toThrow( 'Error loading chain configuration. Ensure you have the correct chain ID.', ); expect(consoleErrorSpy).toHaveBeenCalledWith( new Error('Chain config error'), ); consoleErrorSpy.mockRestore(); // Restore console.error after test }); it('should render empty fragment when config is null', async () => { (useChainConfig as Mock).mockReturnValue({ config: null, error: null, }); const result = await renderBridgeProvider(); expect(result.current).toBeNull(); }); it('should not fetch balance when address is not set', async () => { (useAccount as Mock).mockReturnValue({ address: '', }); const result = await renderBridgeProvider(); expect(result.current.balance).toBe(''); }); it('should fetch ERC-20 balance correctly', async () => { // Mock an ERC-20 token const mockERC20Token = { ...mockToken, address: '0xTokenAddress', decimals: 6, } as BridgeableToken; // Mock readContract to return a specific balance (readContract as Mock).mockResolvedValue(1000000n); const result = await renderBridgeProvider({ bridgeableTokens: [mockERC20Token], }); // Wait for the balance to be fetched await waitFor(() => { expect(readContract).toHaveBeenCalledWith(expect.anything(), { abi: expect.arrayContaining([ expect.objectContaining({ name: 'balanceOf', }), ]), address: '0xTokenAddress', args: ['0x123'], chainId: mockChain.id, functionName: 'balanceOf', }); expect(result.current.balance).toBe('1'); }); }); it('should fetch native gas token balance correctly for withdrawals', async () => { // Mock an ERC-20 token that's a custom gas token const mockERC20Token = { ...mockToken, address: '0xTokenAddress', decimals: 6, isCustomGasToken: true, } as BridgeableToken; (getBalance as Mock).mockResolvedValue({ value: 1000000000000n, decimals: 18, }); const result = await renderBridgeProvider({ bridgeableTokens: [mockERC20Token], }); // Wait for the balance to be fetched await act(async () => { result.current.handleToggle(); }); expect(getBalance).toHaveBeenCalledWith(expect.anything(), { address: '0x123', chainId: mockChain.id, }); expect(result.current.direction).toBe('withdraw'); expect(result.current.bridgeParams.token).toBe(mockERC20Token); }); it('should call withdraw function when handleWithdraw is called', async () => { const mockWithdraw = vi.fn(); (useWithdraw as Mock).mockReturnValue({ withdraw: mockWithdraw, withdrawStatus: 'idle', resetWithdrawStatus: vi.fn(), }); const result = await renderBridgeProvider(); await act(async () => { result.current.handleWithdraw(); }); expect(mockWithdraw).toHaveBeenCalled(); }); it('should open withdraw modal when withdraw is successful', async () => { (useWithdraw as Mock).mockReturnValue({ withdrawStatus: 'withdrawSuccess', resetWithdrawStatus: vi.fn(), }); const result = await renderBridgeProvider(); await act(async () => { expect(result.current.isWithdrawModalOpen).toBe(true); }); }); it('should open success modal when withdraw is successful', async () => { (useWithdraw as Mock).mockReturnValue({ withdrawStatus: 'claimSuccess', resetWithdrawStatus: vi.fn(), }); const result = await renderBridgeProvider(); await act(async () => { expect(result.current.isSuccessModalOpen).toBe(true); }); }); it('should open the correct explorer', async () => { const baseChain = { ...mockChain, id: 8453, // Base mainnet chain ID }; const baseResult = await renderBridgeProvider({ chain: baseChain, appchain: mockAppchain, }); await act(async () => { baseResult.current.handleOpenExplorer(); }); expect(window.open).toHaveBeenCalledWith( 'https://basescan.org/tx/undefined', '_blank', ); // Reset mocks vi.clearAllMocks(); const sepoliaChain = { ...mockChain, id: 84532, // Base Sepolia chain ID }; const sepoliaResult = await renderBridgeProvider({ chain: sepoliaChain, appchain: mockAppchain, }); await act(async () => { sepoliaResult.current.handleOpenExplorer(); }); expect(window.open).toHaveBeenCalledWith( 'https://sepolia.basescan.org/tx/undefined', '_blank', ); }); it('should open the correct explorer when deposit is successful', async () => { const mockDeposit = vi.fn(); (useDeposit as Mock).mockReturnValue({ deposit: mockDeposit, depositStatus: 'depositSuccess', transactionHash: '0x123', resetDepositStatus: vi.fn(), }); const result = await renderBridgeProvider(); await act(async () => { result.current.handleOpenExplorer(); }); expect(window.open).toHaveBeenCalledWith( 'https://sepolia.basescan.org/tx/0x123', '_blank', ); }); it('should open the correct explorer when withdrawal is successful', async () => { const mockWithdraw = vi.fn(); (useWithdraw as Mock).mockReturnValue({ withdraw: mockWithdraw, withdrawStatus: 'withdrawSuccess', finalizedWithdrawalTxHash: '0x123', resetWithdrawStatus: vi.fn(), }); const result = await renderBridgeProvider(); await waitFor(async () => { result.current.handleOpenExplorer(); }); expect(window.open).toHaveBeenCalledWith( 'https://sepolia.basescan.org/tx/0x123', '_blank', ); }); it('should reset state when handleResetState is called', async () => { const result = await renderBridgeProvider(); await waitFor(async () => { result.current.handleResetState(); }); expect(result.current.resumeWithdrawalTxHash).toBeUndefined(); expect(result.current.isWithdrawModalOpen).toBe(false); expect(result.current.isSuccessModalOpen).toBe(false); expect(result.current.isResumeTransactionModalOpen).toBe(false); }); });