From 1a9a0aa9c73529baabb5bdd66c1a36e02d1b4a01 Mon Sep 17 00:00:00 2001 From: Gabito Esmiapodo <4015436+gabitoesmiapodo@users.noreply.github.com> Date: Mon, 23 Mar 2026 17:19:09 -0300 Subject: [PATCH 01/16] test: add utility and env test coverage (Tier 1) Add test safety net before dependency updates. New files: - src/test-utils.tsx: renderWithProviders, createMockWeb3Status, createMockChain - src/utils/strings.test.ts: truncateStringInTheMiddle and getTruncatedHash (13 tests) - src/utils/getExplorerLink.test.ts: getExplorerLink (6 tests) - src/utils/address.test.ts: isNativeToken (5 tests) - src/env.test.ts: Zod-validated env schema (9 tests) - .env.test: Vitest environment variables for required PUBLIC_ fields --- .env.test | 7 +++ src/env.test.ts | 53 +++++++++++++++++++++ src/test-utils.tsx | 52 ++++++++++++++++++++ src/utils/address.test.ts | 35 ++++++++++++++ src/utils/getExplorerLink.test.ts | 52 ++++++++++++++++++++ src/utils/strings.test.ts | 79 +++++++++++++++++++++++++++++++ 6 files changed, 278 insertions(+) create mode 100644 .env.test create mode 100644 src/env.test.ts create mode 100644 src/test-utils.tsx create mode 100644 src/utils/address.test.ts create mode 100644 src/utils/getExplorerLink.test.ts create mode 100644 src/utils/strings.test.ts diff --git a/.env.test b/.env.test new file mode 100644 index 00000000..f4bf82d2 --- /dev/null +++ b/.env.test @@ -0,0 +1,7 @@ +# Test environment variables for Vitest +PUBLIC_APP_NAME=dAppBooster Test +PUBLIC_NATIVE_TOKEN_ADDRESS=0x0000000000000000000000000000000000000000 +PUBLIC_WALLETCONNECT_PROJECT_ID=test-project-id +PUBLIC_SUBGRAPHS_API_KEY=test-api-key +PUBLIC_SUBGRAPHS_CHAINS_RESOURCE_IDS=1:test:test-resource-id +PUBLIC_SUBGRAPHS_ENVIRONMENT=production diff --git a/src/env.test.ts b/src/env.test.ts new file mode 100644 index 00000000..648db0f6 --- /dev/null +++ b/src/env.test.ts @@ -0,0 +1,53 @@ +import { zeroAddress } from 'viem' +import { describe, expect, it } from 'vitest' + +// env.ts reads import.meta.env at module load time. +// Vitest loads .env.test automatically for the "test" mode, +// so PUBLIC_APP_NAME, PUBLIC_SUBGRAPHS_*, etc. are set via .env.test. +import { env } from './env' + +describe('env', () => { + it('exposes PUBLIC_APP_NAME from test env', () => { + expect(env.PUBLIC_APP_NAME).toBe('dAppBooster Test') + }) + + it('defaults PUBLIC_NATIVE_TOKEN_ADDRESS to zero address when not set', () => { + // .env.test sets it to the zero address explicitly + expect(env.PUBLIC_NATIVE_TOKEN_ADDRESS).toBe(zeroAddress.toLowerCase()) + }) + + it('lowercases PUBLIC_NATIVE_TOKEN_ADDRESS', () => { + expect(env.PUBLIC_NATIVE_TOKEN_ADDRESS).toBe(env.PUBLIC_NATIVE_TOKEN_ADDRESS.toLowerCase()) + }) + + it('defaults PUBLIC_ENABLE_PORTO to true', () => { + expect(env.PUBLIC_ENABLE_PORTO).toBe(true) + }) + + it('defaults PUBLIC_USE_DEFAULT_TOKENS to true', () => { + expect(env.PUBLIC_USE_DEFAULT_TOKENS).toBe(true) + }) + + it('defaults PUBLIC_INCLUDE_TESTNETS to true', () => { + expect(env.PUBLIC_INCLUDE_TESTNETS).toBe(true) + }) + + it('defaults PUBLIC_SUBGRAPHS_ENVIRONMENT to production', () => { + expect(env.PUBLIC_SUBGRAPHS_ENVIRONMENT).toBe('production') + }) + + it('exposes PUBLIC_SUBGRAPHS_API_KEY from test env', () => { + expect(env.PUBLIC_SUBGRAPHS_API_KEY).toBe('test-api-key') + }) + + it('exposes PUBLIC_WALLETCONNECT_PROJECT_ID with empty string default', () => { + // .env.test sets it to 'test-project-id' + expect(typeof env.PUBLIC_WALLETCONNECT_PROJECT_ID).toBe('string') + }) + + it('optional RPC vars are undefined when not set in test env', () => { + // None of the RPC vars are set in .env.test + expect(env.PUBLIC_RPC_MAINNET).toBeUndefined() + expect(env.PUBLIC_RPC_SEPOLIA).toBeUndefined() + }) +}) diff --git a/src/test-utils.tsx b/src/test-utils.tsx new file mode 100644 index 00000000..124d95c3 --- /dev/null +++ b/src/test-utils.tsx @@ -0,0 +1,52 @@ +import { ChakraProvider, createSystem, defaultConfig } from '@chakra-ui/react' +import { render } from '@testing-library/react' +import type { ReactNode } from 'react' +import type { Chain } from 'viem' + +const system = createSystem(defaultConfig) + +/** + * Wraps a component in the providers needed for most tests. + */ +export function renderWithProviders(ui: ReactNode) { + return render({ui}) +} + +/** + * Returns a minimal mock of the useWeb3Status return value. + * Pass overrides to test specific states. + */ +export function createMockWeb3Status(overrides?: Partial>) { + return { ..._mockShape(), ...overrides } +} + +function _mockShape() { + return { + address: undefined as `0x${string}` | undefined, + isConnected: false, + isConnecting: false, + isDisconnected: true, + chainId: undefined as number | undefined, + balance: undefined, + publicClient: undefined, + walletClient: undefined, + disconnect: () => {}, + switchChain: undefined, + } +} + +/** + * Returns a minimal valid viem Chain object for tests. + */ +export function createMockChain(overrides?: Partial): Chain { + return { + id: 1, + name: 'Mock Chain', + nativeCurrency: { name: 'Ether', symbol: 'ETH', decimals: 18 }, + rpcUrls: { default: { http: ['https://mock.rpc.url'] } }, + blockExplorers: { + default: { name: 'MockExplorer', url: 'https://mock.explorer.url' }, + }, + ...overrides, + } as Chain +} diff --git a/src/utils/address.test.ts b/src/utils/address.test.ts new file mode 100644 index 00000000..6a49fd05 --- /dev/null +++ b/src/utils/address.test.ts @@ -0,0 +1,35 @@ +import { zeroAddress } from 'viem' +import { describe, expect, it, vi } from 'vitest' + +// Mock env before importing isNativeToken so the module sees the mock +vi.mock('@/src/env', () => ({ + env: { + PUBLIC_NATIVE_TOKEN_ADDRESS: zeroAddress.toLowerCase(), + }, +})) + +import { isNativeToken } from './address' + +describe('isNativeToken', () => { + it('returns true for the zero address (default native token)', () => { + expect(isNativeToken(zeroAddress)).toBe(true) + }) + + it('returns true for the zero address in lowercase', () => { + expect(isNativeToken(zeroAddress.toLowerCase())).toBe(true) + }) + + it('returns true for a checksummed zero address', () => { + // zeroAddress is already lowercase, but ensure uppercase hex still matches + expect(isNativeToken('0x0000000000000000000000000000000000000000')).toBe(true) + }) + + it('returns false for a regular ERC20 contract address', () => { + expect(isNativeToken('0x71C7656EC7ab88b098defB751B7401B5f6d8976F')).toBe(false) + }) + + it('comparison is case-insensitive', () => { + // Both upper and lower case should match the native token (zero address) + expect(isNativeToken('0X0000000000000000000000000000000000000000')).toBe(true) + }) +}) diff --git a/src/utils/getExplorerLink.test.ts b/src/utils/getExplorerLink.test.ts new file mode 100644 index 00000000..31398572 --- /dev/null +++ b/src/utils/getExplorerLink.test.ts @@ -0,0 +1,52 @@ +import { createMockChain } from '@/src/test-utils' +import type { Chain } from 'viem' +import { describe, expect, it } from 'vitest' +import { getExplorerLink } from './getExplorerLink' + +const chain = createMockChain() +// A valid address (40 hex chars after 0x) +const address = '0x71C7656EC7ab88b098defB751B7401B5f6d8976F' as const +// A valid tx hash (64 hex chars after 0x) +const txHash = '0xd85ef8c70dc31a4f8d5bf0331e1eac886935905f15d32e71b348df745cd38e19' as const + +describe('getExplorerLink', () => { + it('returns address URL using chain block explorer', () => { + const url = getExplorerLink({ chain, hashOrAddress: address }) + expect(url).toBe(`${chain.blockExplorers?.default.url}/address/${address}`) + }) + + it('returns tx URL using chain block explorer for a hash', () => { + const url = getExplorerLink({ chain, hashOrAddress: txHash }) + expect(url).toBe(`${chain.blockExplorers?.default.url}/tx/${txHash}`) + }) + + it('uses custom explorerUrl for an address', () => { + const explorerUrl = 'https://custom.explorer.io' + const url = getExplorerLink({ chain, hashOrAddress: address, explorerUrl }) + expect(url).toBe(`${explorerUrl}/address/${address}`) + }) + + it('uses custom explorerUrl for a tx hash', () => { + const explorerUrl = 'https://custom.explorer.io' + const url = getExplorerLink({ chain, hashOrAddress: txHash, explorerUrl }) + expect(url).toBe(`${explorerUrl}/tx/${txHash}`) + }) + + it('throws for an invalid hash or address', () => { + expect(() => + // biome-ignore lint/suspicious/noExplicitAny: intentionally testing invalid input + getExplorerLink({ chain, hashOrAddress: 'not-valid' as any }), + ).toThrow('Invalid hash or address') + }) + + it('works with a chain that has no default block explorer (explorerUrl provided)', () => { + const chainWithoutExplorer: Chain = { ...chain, blockExplorers: undefined } + const explorerUrl = 'https://fallback.explorer.io' + const url = getExplorerLink({ + chain: chainWithoutExplorer, + hashOrAddress: address, + explorerUrl, + }) + expect(url).toBe(`${explorerUrl}/address/${address}`) + }) +}) diff --git a/src/utils/strings.test.ts b/src/utils/strings.test.ts new file mode 100644 index 00000000..e3e8d775 --- /dev/null +++ b/src/utils/strings.test.ts @@ -0,0 +1,79 @@ +import { describe, expect, it } from 'vitest' +import { getTruncatedHash, truncateStringInTheMiddle } from './strings' + +describe('truncateStringInTheMiddle', () => { + it('truncates a long string keeping start and end', () => { + const result = truncateStringInTheMiddle('0x1234567890abcdef1234567890abcdef12345678', 8, 6) + expect(result).toBe('0x123456...345678') + }) + + it('returns the original string when it fits within start + end length', () => { + const result = truncateStringInTheMiddle('short', 4, 4) + expect(result).toBe('short') + }) + + it('returns the original string when length equals start + end exactly', () => { + const result = truncateStringInTheMiddle('1234567890', 5, 5) + expect(result).toBe('1234567890') + }) + + it('truncates when length exceeds start + end by one', () => { + const result = truncateStringInTheMiddle('12345678901', 5, 5) + expect(result).toBe('12345...78901') + }) + + it('handles empty string', () => { + const result = truncateStringInTheMiddle('', 4, 4) + expect(result).toBe('') + }) + + it('handles asymmetric start and end positions', () => { + const result = truncateStringInTheMiddle('abcdefghijklmnop', 3, 7) + expect(result).toBe('abc...jklmnop') + }) + + it('handles start position of 0', () => { + const result = truncateStringInTheMiddle('abcdefghij', 0, 3) + expect(result).toBe('...hij') + }) +}) + +describe('getTruncatedHash', () => { + const address = '0x1234567890abcdef1234567890abcdef12345678' + const txHash = '0xd85ef8c70dc31a4f8d5bf0331e1eac886935905f15d32e71b348df745cd38e19' + + it('truncates with default length of 6', () => { + const result = getTruncatedHash(address) + // 0x + 6 chars ... last 6 chars + expect(result).toBe('0x123456...345678') + }) + + it('truncates with custom length', () => { + const result = getTruncatedHash(address, 4) + // 0x + 4 chars ... last 4 chars + expect(result).toBe('0x1234...5678') + }) + + it('truncates a transaction hash', () => { + const result = getTruncatedHash(txHash, 6) + expect(result).toBe('0xd85ef8...d38e19') + }) + + it('clamps length to minimum of 1', () => { + const result = getTruncatedHash(address, 0) + // length clamped to 1: 0x + 1 char ... last 1 char + expect(result).toBe('0x1...8') + }) + + it('clamps length to maximum of 16', () => { + const result = getTruncatedHash(address, 100) + // address = '0x1234567890abcdef1234567890abcdef12345678' (42 chars) + // length clamped to 16: slice(0, 18) = '0x1234567890abcdef', slice(42-16, 42) = '90abcdef12345678' + expect(result).toBe('0x1234567890abcdef...90abcdef12345678') + }) + + it('handles negative length by clamping to 1', () => { + const result = getTruncatedHash(address, -5) + expect(result).toBe('0x1...8') + }) +}) From 7edb47f9b971224b3f07f408e665ea4313f97127 Mon Sep 17 00:00:00 2001 From: Gabito Esmiapodo <4015436+gabitoesmiapodo@users.noreply.github.com> Date: Mon, 23 Mar 2026 18:55:14 -0300 Subject: [PATCH 02/16] test: address review feedback on Tier 1 test suite - Fix env.test.ts test names that incorrectly claimed to test defaults while .env.test was setting those vars explicitly - Assert exact value for PUBLIC_WALLETCONNECT_PROJECT_ID (was typeof check) - Fix createMockWeb3Status shape to match actual Web3Status interface (was using isConnected/chainId/publicClient; hook uses isWalletConnected/ walletChainId/readOnlyClient) - Hardcode expected explorer URL in getExplorerLink assertions instead of deriving from mock chain (prevents false positives if mock misconfigured) --- src/env.test.ts | 11 ++++++----- src/test-utils.tsx | 17 +++++++++++------ src/utils/getExplorerLink.test.ts | 4 ++-- 3 files changed, 19 insertions(+), 13 deletions(-) diff --git a/src/env.test.ts b/src/env.test.ts index 648db0f6..7abfc60f 100644 --- a/src/env.test.ts +++ b/src/env.test.ts @@ -11,8 +11,8 @@ describe('env', () => { expect(env.PUBLIC_APP_NAME).toBe('dAppBooster Test') }) - it('defaults PUBLIC_NATIVE_TOKEN_ADDRESS to zero address when not set', () => { - // .env.test sets it to the zero address explicitly + it('reads and normalizes PUBLIC_NATIVE_TOKEN_ADDRESS from env', () => { + // .env.test sets it to the zero address; the schema lowercases the value expect(env.PUBLIC_NATIVE_TOKEN_ADDRESS).toBe(zeroAddress.toLowerCase()) }) @@ -32,7 +32,8 @@ describe('env', () => { expect(env.PUBLIC_INCLUDE_TESTNETS).toBe(true) }) - it('defaults PUBLIC_SUBGRAPHS_ENVIRONMENT to production', () => { + it('reads PUBLIC_SUBGRAPHS_ENVIRONMENT from test env', () => { + // .env.test sets it to 'production'; to test the schema default use vi.resetModules() expect(env.PUBLIC_SUBGRAPHS_ENVIRONMENT).toBe('production') }) @@ -40,9 +41,9 @@ describe('env', () => { expect(env.PUBLIC_SUBGRAPHS_API_KEY).toBe('test-api-key') }) - it('exposes PUBLIC_WALLETCONNECT_PROJECT_ID with empty string default', () => { + it('exposes PUBLIC_WALLETCONNECT_PROJECT_ID from test env', () => { // .env.test sets it to 'test-project-id' - expect(typeof env.PUBLIC_WALLETCONNECT_PROJECT_ID).toBe('string') + expect(env.PUBLIC_WALLETCONNECT_PROJECT_ID).toBe('test-project-id') }) it('optional RPC vars are undefined when not set in test env', () => { diff --git a/src/test-utils.tsx b/src/test-utils.tsx index 124d95c3..d60fd802 100644 --- a/src/test-utils.tsx +++ b/src/test-utils.tsx @@ -22,16 +22,21 @@ export function createMockWeb3Status(overrides?: Partial {}, - switchChain: undefined, + switchChain: (_chainId?: number) => {}, } } diff --git a/src/utils/getExplorerLink.test.ts b/src/utils/getExplorerLink.test.ts index 31398572..c7ca4fad 100644 --- a/src/utils/getExplorerLink.test.ts +++ b/src/utils/getExplorerLink.test.ts @@ -12,12 +12,12 @@ const txHash = '0xd85ef8c70dc31a4f8d5bf0331e1eac886935905f15d32e71b348df745cd38e describe('getExplorerLink', () => { it('returns address URL using chain block explorer', () => { const url = getExplorerLink({ chain, hashOrAddress: address }) - expect(url).toBe(`${chain.blockExplorers?.default.url}/address/${address}`) + expect(url).toBe(`https://mock.explorer.url/address/${address}`) }) it('returns tx URL using chain block explorer for a hash', () => { const url = getExplorerLink({ chain, hashOrAddress: txHash }) - expect(url).toBe(`${chain.blockExplorers?.default.url}/tx/${txHash}`) + expect(url).toBe(`https://mock.explorer.url/tx/${txHash}`) }) it('uses custom explorerUrl for an address', () => { From 55510e5d0c6704222c0c28d29a1cea4a536eb42e Mon Sep 17 00:00:00 2001 From: Gabito Esmiapodo <4015436+gabitoesmiapodo@users.noreply.github.com> Date: Mon, 23 Mar 2026 17:21:33 -0300 Subject: [PATCH 03/16] test: enhance BigNumberInput and HashInput coverage (Tier 2) BigNumberInput: add 9 tests covering placeholder, disabled state, onChange with parsed bigint, onError for min/max violations, decimal precision enforcement, and input clearing. HashInput: add 7 tests covering placeholder, typed value reflection, initial value, clear to null, debounced search callback, onLoading lifecycle, and custom renderInput. --- .../sharedComponents/BigNumberInput.test.tsx | 126 ++++++++++++++++-- .../sharedComponents/HashInput.test.tsx | 95 ++++++++++++- 2 files changed, 202 insertions(+), 19 deletions(-) diff --git a/src/components/sharedComponents/BigNumberInput.test.tsx b/src/components/sharedComponents/BigNumberInput.test.tsx index 7554dc5a..a28532d6 100644 --- a/src/components/sharedComponents/BigNumberInput.test.tsx +++ b/src/components/sharedComponents/BigNumberInput.test.tsx @@ -1,24 +1,126 @@ import { ChakraProvider, createSystem, defaultConfig } from '@chakra-ui/react' import { render, screen } from '@testing-library/react' -import { describe, expect, it } from 'vitest' +import userEvent from '@testing-library/user-event' +import { maxUint256 } from 'viem' +import { describe, expect, it, vi } from 'vitest' import { BigNumberInput } from './BigNumberInput' const system = createSystem(defaultConfig) +function renderInput( + props: Partial> & { + onChange?: (v: bigint) => void + } = {}, +) { + const onChange = props.onChange ?? vi.fn() + const { container } = render( + + + , + ) + return { input: screen.getByRole('textbox') as HTMLInputElement, onChange, container } +} + describe('BigNumberInput', () => { it('renders without crashing', () => { - render( - - {}} - /> - , - ) - - const input = screen.getByRole('textbox') + const { input } = renderInput() expect(input).not.toBeNull() expect(input.tagName).toBe('INPUT') }) + + it('renders with default placeholder', () => { + const { input } = renderInput() + expect(input.placeholder).toBe('0.00') + }) + + it('renders with custom placeholder', () => { + const { input } = renderInput({ placeholder: 'Amount' }) + expect(input.placeholder).toBe('Amount') + }) + + it('renders as disabled when disabled prop is set', () => { + const { input } = renderInput({ disabled: true }) + expect(input.disabled).toBe(true) + }) + + it('calls onChange with BigInt(0) when input is cleared after typing', async () => { + const onChange = vi.fn() + renderInput({ decimals: 0, onChange }) + const input = screen.getByRole('textbox') + await userEvent.type(input, '5') + await userEvent.clear(input) + expect(onChange).toHaveBeenLastCalledWith(BigInt(0)) + }) + + it('calls onChange with the parsed bigint when a valid value is typed', async () => { + const onChange = vi.fn() + renderInput({ decimals: 6, onChange }) + const input = screen.getByRole('textbox') + await userEvent.type(input, '1.5') + // 1.5 * 10^6 = 1500000 + expect(onChange).toHaveBeenLastCalledWith(BigInt(1_500_000)) + }) + + it('calls onError when value exceeds max', async () => { + const onChange = vi.fn() + const onError = vi.fn() + renderInput({ + decimals: 0, + max: BigInt(100), + onChange, + onError, + }) + const input = screen.getByRole('textbox') + await userEvent.type(input, '200') + expect(onError).toHaveBeenCalledWith(expect.objectContaining({ value: '200' })) + }) + + it('calls onError when value is below min', async () => { + const onChange = vi.fn() + const onError = vi.fn() + renderInput({ + decimals: 0, + min: BigInt(10), + onChange, + onError, + }) + const input = screen.getByRole('textbox') + await userEvent.type(input, '5') + expect(onError).toHaveBeenCalledWith(expect.objectContaining({ value: '5' })) + }) + + it('calls onError(null) is NOT called for valid values (no error clearing tested via absence)', async () => { + const onError = vi.fn() + renderInput({ decimals: 0, max: BigInt(100), onError }) + const input = screen.getByRole('textbox') + await userEvent.type(input, '50') + // onError is only called on invalid values; valid value should not trigger it + expect(onError).not.toHaveBeenCalled() + }) + + it('ignores the extra digit when input has too many decimals', async () => { + const onChange = vi.fn() + renderInput({ decimals: 2, onChange }) + const input = screen.getByRole('textbox') + await userEvent.type(input, '1.123') + // '1.12' is valid (2 decimals), '1.123' has 3 and is ignored + // Last valid call should be for 1.12 = 112n (2 decimals) + const lastCall = onChange.mock.calls.at(-1)?.[0] + expect(lastCall).toBe(BigInt(112)) + }) + + it('does not update on maxUint256 overflow', async () => { + const onError = vi.fn() + renderInput({ decimals: 0, max: maxUint256, onError }) + // Typing an absurdly large number won't overflow since max = maxUint256 + // Just verify no onError for normal large numbers + const input = screen.getByRole('textbox') + await userEvent.type(input, '9999999') + expect(onError).not.toHaveBeenCalled() + }) }) diff --git a/src/components/sharedComponents/HashInput.test.tsx b/src/components/sharedComponents/HashInput.test.tsx index 72f11389..0506e9ad 100644 --- a/src/components/sharedComponents/HashInput.test.tsx +++ b/src/components/sharedComponents/HashInput.test.tsx @@ -1,28 +1,109 @@ import { ChakraProvider, createSystem, defaultConfig } from '@chakra-ui/react' -import { render, screen } from '@testing-library/react' +import { render, screen, waitFor } from '@testing-library/react' +import userEvent from '@testing-library/user-event' import { mainnet } from 'viem/chains' import { describe, expect, it, vi } from 'vitest' import HashInput from './HashInput' const system = createSystem(defaultConfig) +const detectHashMock = vi.fn().mockResolvedValue({ type: 'EOA', data: '0xabc' }) vi.mock('@/src/utils/hash', () => ({ - default: vi.fn().mockResolvedValue(null), + default: (...args: unknown[]) => detectHashMock(...args), })) +function renderHashInput(props: Partial> = {}) { + const onSearch = props.onSearch ?? vi.fn() + render( + + + , + ) + return { input: screen.getByTestId('hash-input') as HTMLInputElement, onSearch } +} + describe('HashInput', () => { it('renders without crashing', () => { + const { input } = renderHashInput() + expect(input).not.toBeNull() + expect(input.tagName).toBe('INPUT') + }) + + it('renders with placeholder prop', () => { + renderHashInput({ placeholder: 'Enter address or hash' }) + expect(screen.getByPlaceholderText('Enter address or hash')).toBeDefined() + }) + + it('reflects typed value in the input', async () => { + const { input } = renderHashInput() + await userEvent.type(input, 'test.eth') + expect(input.value).toBe('test.eth') + }) + + it('renders with initial value', () => { + renderHashInput({ value: '0xInitial' }) + const input = screen.getByTestId('hash-input') as HTMLInputElement + expect(input.value).toBe('0xInitial') + }) + + it('calls onSearch with null when input is cleared', async () => { + const onSearch = vi.fn() + const { input } = renderHashInput({ onSearch }) + await userEvent.type(input, 'abc') + await userEvent.clear(input) + await waitFor(() => { + expect(onSearch).toHaveBeenLastCalledWith(null) + }) + }) + + it('calls onSearch with detection result after debounce', async () => { + const onSearch = vi.fn() + detectHashMock.mockResolvedValueOnce({ type: 'ENS', data: '0x123' }) + renderHashInput({ onSearch, debounceTime: 0 }) + const input = screen.getByTestId('hash-input') + await userEvent.type(input, 'vitalik.eth') + await waitFor(() => { + expect(onSearch).toHaveBeenCalledWith({ type: 'ENS', data: '0x123' }) + }) + }) + + it('calls onLoading(true) then onLoading(false) during search', async () => { + const onLoading = vi.fn() + let resolveDetect!: (v: unknown) => void + detectHashMock.mockImplementationOnce( + () => + new Promise((res) => { + resolveDetect = res + }), + ) + renderHashInput({ onLoading, debounceTime: 0 }) + const input = screen.getByTestId('hash-input') + await userEvent.type(input, 'q') + await waitFor(() => expect(onLoading).toHaveBeenCalledWith(true)) + resolveDetect({ type: null, data: null }) + await waitFor(() => expect(onLoading).toHaveBeenCalledWith(false)) + }) + + it('uses custom renderInput when provided', () => { render( {}} + onSearch={vi.fn()} + renderInput={(props) => ( + + )} /> , ) - - const input = screen.getByTestId('hash-input') - expect(input).not.toBeNull() - expect(input.tagName).toBe('INPUT') + expect(screen.getByTestId('custom-input')).toBeDefined() }) }) From 33e3ca302dff75f9cd16b17fb1f5cf35d9c466e2 Mon Sep 17 00:00:00 2001 From: Gabito Esmiapodo <4015436+gabitoesmiapodo@users.noreply.github.com> Date: Mon, 23 Mar 2026 18:58:56 -0300 Subject: [PATCH 04/16] test: address review feedback on Tier 2 test suite - Rename misleading test names in BigNumberInput (onError absence test, maxUint256 constraint test) - Add beforeEach mock clear in HashInput to prevent cross-test contamination from unconsumed mockResolvedValueOnce calls - Add debounceTime: 0 to clear test to avoid real-timer dependency - Rename debounce test to accurately describe what it covers; fake-timer debounce testing conflicts with waitFor + userEvent in jsdom --- .../sharedComponents/BigNumberInput.test.tsx | 7 +++---- src/components/sharedComponents/HashInput.test.tsx | 14 +++++++++++--- 2 files changed, 14 insertions(+), 7 deletions(-) diff --git a/src/components/sharedComponents/BigNumberInput.test.tsx b/src/components/sharedComponents/BigNumberInput.test.tsx index a28532d6..8659683d 100644 --- a/src/components/sharedComponents/BigNumberInput.test.tsx +++ b/src/components/sharedComponents/BigNumberInput.test.tsx @@ -94,7 +94,7 @@ describe('BigNumberInput', () => { expect(onError).toHaveBeenCalledWith(expect.objectContaining({ value: '5' })) }) - it('calls onError(null) is NOT called for valid values (no error clearing tested via absence)', async () => { + it('does not call onError for valid values', async () => { const onError = vi.fn() renderInput({ decimals: 0, max: BigInt(100), onError }) const input = screen.getByRole('textbox') @@ -114,11 +114,10 @@ describe('BigNumberInput', () => { expect(lastCall).toBe(BigInt(112)) }) - it('does not update on maxUint256 overflow', async () => { + it('does not call onError when value is within max constraint', async () => { const onError = vi.fn() renderInput({ decimals: 0, max: maxUint256, onError }) - // Typing an absurdly large number won't overflow since max = maxUint256 - // Just verify no onError for normal large numbers + // Large but valid number — well within maxUint256, so no error const input = screen.getByRole('textbox') await userEvent.type(input, '9999999') expect(onError).not.toHaveBeenCalled() diff --git a/src/components/sharedComponents/HashInput.test.tsx b/src/components/sharedComponents/HashInput.test.tsx index 0506e9ad..4d99df13 100644 --- a/src/components/sharedComponents/HashInput.test.tsx +++ b/src/components/sharedComponents/HashInput.test.tsx @@ -2,7 +2,7 @@ import { ChakraProvider, createSystem, defaultConfig } from '@chakra-ui/react' import { render, screen, waitFor } from '@testing-library/react' import userEvent from '@testing-library/user-event' import { mainnet } from 'viem/chains' -import { describe, expect, it, vi } from 'vitest' +import { beforeEach, describe, expect, it, vi } from 'vitest' import HashInput from './HashInput' const system = createSystem(defaultConfig) @@ -27,6 +27,11 @@ function renderHashInput(props: Partial> } describe('HashInput', () => { + beforeEach(() => { + detectHashMock.mockClear() + detectHashMock.mockResolvedValue({ type: 'EOA', data: '0xabc' }) + }) + it('renders without crashing', () => { const { input } = renderHashInput() expect(input).not.toBeNull() @@ -52,7 +57,8 @@ describe('HashInput', () => { it('calls onSearch with null when input is cleared', async () => { const onSearch = vi.fn() - const { input } = renderHashInput({ onSearch }) + // debounceTime: 0 to avoid relying on real timers for non-debounce behavior + const { input } = renderHashInput({ onSearch, debounceTime: 0 }) await userEvent.type(input, 'abc') await userEvent.clear(input) await waitFor(() => { @@ -60,7 +66,9 @@ describe('HashInput', () => { }) }) - it('calls onSearch with detection result after debounce', async () => { + it('calls onSearch with the detected result', async () => { + // debounceTime: 0 keeps the test fast; this covers the callback wiring, + // not the debounce delay itself (fake timers + waitFor + userEvent conflict in jsdom) const onSearch = vi.fn() detectHashMock.mockResolvedValueOnce({ type: 'ENS', data: '0x123' }) renderHashInput({ onSearch, debounceTime: 0 }) From 40e06d3e6aa2628f86a5ee20fe59fc5d52a00924 Mon Sep 17 00:00:00 2001 From: Gabito Esmiapodo <4015436+gabitoesmiapodo@users.noreply.github.com> Date: Mon, 23 Mar 2026 20:52:46 -0300 Subject: [PATCH 05/16] test: address review feedback on Tier 1 test suite (round 2) - Use @/src/env alias import in env.test.ts (matches repo convention) - Rename 'checksummed zero address' test to 'zero address string literal' (no checksum casing difference in this value; name was misleading) --- src/env.test.ts | 2 +- src/utils/address.test.ts | 4 ++-- 2 files changed, 3 insertions(+), 3 deletions(-) diff --git a/src/env.test.ts b/src/env.test.ts index 7abfc60f..07c3a923 100644 --- a/src/env.test.ts +++ b/src/env.test.ts @@ -4,7 +4,7 @@ import { describe, expect, it } from 'vitest' // env.ts reads import.meta.env at module load time. // Vitest loads .env.test automatically for the "test" mode, // so PUBLIC_APP_NAME, PUBLIC_SUBGRAPHS_*, etc. are set via .env.test. -import { env } from './env' +import { env } from '@/src/env' describe('env', () => { it('exposes PUBLIC_APP_NAME from test env', () => { diff --git a/src/utils/address.test.ts b/src/utils/address.test.ts index 6a49fd05..754488d8 100644 --- a/src/utils/address.test.ts +++ b/src/utils/address.test.ts @@ -19,8 +19,8 @@ describe('isNativeToken', () => { expect(isNativeToken(zeroAddress.toLowerCase())).toBe(true) }) - it('returns true for a checksummed zero address', () => { - // zeroAddress is already lowercase, but ensure uppercase hex still matches + it('returns true for the zero address string literal', () => { + // zeroAddress is already lowercase; the literal string is identical — testing the exact value expect(isNativeToken('0x0000000000000000000000000000000000000000')).toBe(true) }) From 543f5f4eda7e046ca75d471d82e08c985a0d3e6e Mon Sep 17 00:00:00 2001 From: Gabito Esmiapodo <4015436+gabitoesmiapodo@users.noreply.github.com> Date: Mon, 23 Mar 2026 20:55:34 -0300 Subject: [PATCH 06/16] test: import ComponentProps from react instead of using React namespace React global namespace is not available without explicit import; use named import from 'react' to avoid 'Cannot find namespace React' errors. --- src/components/sharedComponents/BigNumberInput.test.tsx | 3 ++- src/components/sharedComponents/HashInput.test.tsx | 3 ++- 2 files changed, 4 insertions(+), 2 deletions(-) diff --git a/src/components/sharedComponents/BigNumberInput.test.tsx b/src/components/sharedComponents/BigNumberInput.test.tsx index 8659683d..03588853 100644 --- a/src/components/sharedComponents/BigNumberInput.test.tsx +++ b/src/components/sharedComponents/BigNumberInput.test.tsx @@ -1,6 +1,7 @@ import { ChakraProvider, createSystem, defaultConfig } from '@chakra-ui/react' import { render, screen } from '@testing-library/react' import userEvent from '@testing-library/user-event' +import type { ComponentProps } from 'react' import { maxUint256 } from 'viem' import { describe, expect, it, vi } from 'vitest' import { BigNumberInput } from './BigNumberInput' @@ -8,7 +9,7 @@ import { BigNumberInput } from './BigNumberInput' const system = createSystem(defaultConfig) function renderInput( - props: Partial> & { + props: Partial> & { onChange?: (v: bigint) => void } = {}, ) { diff --git a/src/components/sharedComponents/HashInput.test.tsx b/src/components/sharedComponents/HashInput.test.tsx index 4d99df13..34a8c3df 100644 --- a/src/components/sharedComponents/HashInput.test.tsx +++ b/src/components/sharedComponents/HashInput.test.tsx @@ -1,6 +1,7 @@ import { ChakraProvider, createSystem, defaultConfig } from '@chakra-ui/react' import { render, screen, waitFor } from '@testing-library/react' import userEvent from '@testing-library/user-event' +import type { ComponentProps } from 'react' import { mainnet } from 'viem/chains' import { beforeEach, describe, expect, it, vi } from 'vitest' import HashInput from './HashInput' @@ -12,7 +13,7 @@ vi.mock('@/src/utils/hash', () => ({ default: (...args: unknown[]) => detectHashMock(...args), })) -function renderHashInput(props: Partial> = {}) { +function renderHashInput(props: Partial> = {}) { const onSearch = props.onSearch ?? vi.fn() render( From 2b605b6595cf27a754aa385e2578476448b55f49 Mon Sep 17 00:00:00 2001 From: Gabito Esmiapodo <4015436+gabitoesmiapodo@users.noreply.github.com> Date: Mon, 23 Mar 2026 17:25:10 -0300 Subject: [PATCH 07/16] test: add hook test coverage (Tier 3) - useWeb3Status.test.ts: 8 tests covering disconnected/connected state, isWalletSynced, switchingChain, disconnect, switchChain, appChainId - useWeb3StatusConnected.test.ts: 2 tests (co-located in same file) covering throw-on-disconnect and pass-through when connected - useErc20Balance.test.ts: 5 tests covering missing address/token, native token skip, successful balance fetch, and error handling - useTokenLists.test.ts: 4 tests covering return shape, deduplication, native token injection, and schema validation filtering --- src/hooks/useErc20Balance.test.ts | 82 ++++++++++++++++++ src/hooks/useTokenLists.test.ts | 136 ++++++++++++++++++++++++++++++ src/hooks/useWeb3Status.test.ts | 119 ++++++++++++++++++++++++++ 3 files changed, 337 insertions(+) create mode 100644 src/hooks/useErc20Balance.test.ts create mode 100644 src/hooks/useTokenLists.test.ts create mode 100644 src/hooks/useWeb3Status.test.ts diff --git a/src/hooks/useErc20Balance.test.ts b/src/hooks/useErc20Balance.test.ts new file mode 100644 index 00000000..28ff74ee --- /dev/null +++ b/src/hooks/useErc20Balance.test.ts @@ -0,0 +1,82 @@ +import type { Token } from '@/src/types/token' +import { QueryClient, QueryClientProvider } from '@tanstack/react-query' +import { renderHook, waitFor } from '@testing-library/react' +import type { ReactNode } from 'react' +import { createElement } from 'react' +import { zeroAddress } from 'viem' +import { describe, expect, it, vi } from 'vitest' +import { useErc20Balance } from './useErc20Balance' + +const mockReadContract = vi.fn() + +vi.mock('wagmi', () => ({ + usePublicClient: vi.fn(() => ({ + readContract: mockReadContract, + })), +})) + +vi.mock('@/src/env', () => ({ + env: { PUBLIC_NATIVE_TOKEN_ADDRESS: zeroAddress.toLowerCase() }, +})) + +const wrapper = ({ children }: { children: ReactNode }) => + createElement( + QueryClientProvider, + { client: new QueryClient({ defaultOptions: { queries: { retry: false } } }) }, + children, + ) + +const mockToken: Token = { + address: '0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48', + chainId: 1, + decimals: 6, + name: 'USD Coin', + symbol: 'USDC', +} + +const walletAddress = '0x71C7656EC7ab88b098defB751B7401B5f6d8976F' as `0x${string}` + +describe('useErc20Balance', () => { + it('returns undefined balance when address is missing', () => { + const { result } = renderHook(() => useErc20Balance({ token: mockToken }), { wrapper }) + expect(result.current.balance).toBeUndefined() + expect(result.current.isLoadingBalance).toBe(false) + }) + + it('returns undefined balance when token is missing', () => { + const { result } = renderHook(() => useErc20Balance({ address: walletAddress }), { wrapper }) + expect(result.current.balance).toBeUndefined() + expect(result.current.isLoadingBalance).toBe(false) + }) + + it('does not fetch balance for native token address', () => { + const nativeToken: Token = { ...mockToken, address: zeroAddress } + const { result } = renderHook( + () => useErc20Balance({ address: walletAddress, token: nativeToken }), + { wrapper }, + ) + expect(mockReadContract).not.toHaveBeenCalled() + expect(result.current.isLoadingBalance).toBe(false) + }) + + it('returns balance when query resolves', async () => { + mockReadContract.mockResolvedValueOnce(BigInt(1_000_000)) + const { result } = renderHook( + () => useErc20Balance({ address: walletAddress, token: mockToken }), + { wrapper }, + ) + await waitFor(() => expect(result.current.isLoadingBalance).toBe(false)) + expect(result.current.balance).toBe(BigInt(1_000_000)) + expect(result.current.balanceError).toBeNull() + }) + + it('returns error when query fails', async () => { + mockReadContract.mockRejectedValueOnce(new Error('RPC error')) + const { result } = renderHook( + () => useErc20Balance({ address: walletAddress, token: mockToken }), + { wrapper }, + ) + await waitFor(() => expect(result.current.balanceError).toBeTruthy()) + expect(result.current.balance).toBeUndefined() + }) +}) diff --git a/src/hooks/useTokenLists.test.ts b/src/hooks/useTokenLists.test.ts new file mode 100644 index 00000000..bb8ddc0f --- /dev/null +++ b/src/hooks/useTokenLists.test.ts @@ -0,0 +1,136 @@ +import type { Token } from '@/src/types/token' +import tokenListsCache, { updateTokenListsCache } from '@/src/utils/tokenListsCache' +import { QueryClient, QueryClientProvider } from '@tanstack/react-query' +import { renderHook } from '@testing-library/react' +import { createElement } from 'react' +import type { ReactNode } from 'react' +import { zeroAddress } from 'viem' +import { beforeEach, describe, expect, it, vi } from 'vitest' + +vi.mock('@/src/utils/tokenListsCache', () => { + const cache = { tokens: [] as Token[], tokensByChainId: {} as Record } + return { + default: cache, + updateTokenListsCache: vi.fn((map: typeof cache) => { + cache.tokens = map.tokens + cache.tokensByChainId = map.tokensByChainId + }), + addTokenToTokenList: vi.fn(), + } +}) + +vi.mock('@/src/env', () => ({ + env: { + PUBLIC_NATIVE_TOKEN_ADDRESS: zeroAddress.toLowerCase(), + PUBLIC_USE_DEFAULT_TOKENS: false, + }, +})) + +vi.mock('@/src/constants/tokenLists', () => ({ + tokenLists: {}, +})) + +vi.mock('@tanstack/react-query', async (importActual) => { + const actual = await importActual() + return { ...actual, useSuspenseQueries: vi.fn() } +}) + +import * as tanstackQuery from '@tanstack/react-query' +import { useTokenLists } from './useTokenLists' + +const mockToken1: Token = { + address: '0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48', + chainId: 1, + decimals: 6, + name: 'USD Coin', + symbol: 'USDC', +} +const mockToken2: Token = { + address: '0xdAC17F958D2ee523a2206206994597C13D831ec7', + chainId: 1, + decimals: 6, + name: 'Tether USD', + symbol: 'USDT', +} + +const mockSuspenseQueryResult = (tokens: Token[]) => ({ + data: { name: 'Mock List', timestamp: '', version: { major: 1, minor: 0, patch: 0 }, tokens }, + isLoading: false, + isSuccess: true, + error: null, +}) + +const wrapper = ({ children }: { children: ReactNode }) => + createElement(QueryClientProvider, { client: new QueryClient() }, children) + +beforeEach(() => { + // Reset cache between tests + tokenListsCache.tokens = [] + tokenListsCache.tokensByChainId = {} + vi.mocked(updateTokenListsCache).mockImplementation((map) => { + tokenListsCache.tokens = map.tokens + tokenListsCache.tokensByChainId = map.tokensByChainId + }) +}) + +describe('useTokenLists', () => { + it('returns tokens and tokensByChainId', () => { + vi.mocked(tanstackQuery.useSuspenseQueries).mockReturnValueOnce( + // biome-ignore lint/suspicious/noExplicitAny: mocking overloaded hook return type + { tokens: [mockToken1], tokensByChainId: { 1: [mockToken1] } } as any, + ) + + const { result } = renderHook(() => useTokenLists(), { wrapper }) + expect(result.current.tokens).toBeDefined() + expect(result.current.tokensByChainId).toBeDefined() + }) + + it('deduplicates tokens with the same chainId and address', () => { + // biome-ignore lint/suspicious/noExplicitAny: mocking internal combine param + vi.mocked(tanstackQuery.useSuspenseQueries).mockImplementation(({ combine }: any) => { + const results = [ + mockSuspenseQueryResult([mockToken1, mockToken2]), + mockSuspenseQueryResult([{ ...mockToken1 }]), // duplicate + ] + return combine(results) + }) + + const { result } = renderHook(() => useTokenLists(), { wrapper }) + const erc20Tokens = result.current.tokens.filter((t) => t.address !== zeroAddress.toLowerCase()) + expect(erc20Tokens).toHaveLength(2) + expect(erc20Tokens.map((t) => t.symbol)).toContain('USDC') + expect(erc20Tokens.map((t) => t.symbol)).toContain('USDT') + }) + + it('injects a native ETH token for mainnet (chainId 1) tokens', () => { + vi.mocked(tanstackQuery.useSuspenseQueries).mockImplementation( + // biome-ignore lint/suspicious/noExplicitAny: mocking internal combine param + ({ combine }: any) => combine([mockSuspenseQueryResult([mockToken1])]), + ) + + const { result } = renderHook(() => useTokenLists(), { wrapper }) + const nativeToken = result.current.tokensByChainId[1]?.[0] + expect(nativeToken?.address).toBe(zeroAddress.toLowerCase()) + expect(nativeToken?.symbol).toBe('ETH') + }) + + it('filters out tokens that fail schema validation', () => { + // biome-ignore lint/suspicious/noExplicitAny: mocking internal combine param + vi.mocked(tanstackQuery.useSuspenseQueries).mockImplementation(({ combine }: any) => { + const invalidToken = { + address: 'not-an-address', + chainId: 1, + name: 'Bad', + symbol: 'BAD', + decimals: 18, + } + // biome-ignore lint/suspicious/noExplicitAny: intentionally testing invalid token input + return combine([mockSuspenseQueryResult([mockToken1, invalidToken as any])]) + }) + + const { result } = renderHook(() => useTokenLists(), { wrapper }) + const erc20Tokens = result.current.tokens.filter((t) => t.address !== zeroAddress.toLowerCase()) + expect(erc20Tokens).toHaveLength(1) + expect(erc20Tokens[0].symbol).toBe('USDC') + }) +}) diff --git a/src/hooks/useWeb3Status.test.ts b/src/hooks/useWeb3Status.test.ts new file mode 100644 index 00000000..83e2a208 --- /dev/null +++ b/src/hooks/useWeb3Status.test.ts @@ -0,0 +1,119 @@ +import { renderHook } from '@testing-library/react' +import { describe, expect, it, vi } from 'vitest' +import { useWeb3Status, useWeb3StatusConnected } from './useWeb3Status' + +const mockDisconnect = vi.fn() +const mockSwitchChain = vi.fn() + +vi.mock('wagmi', () => ({ + useAccount: vi.fn(() => ({ + address: undefined, + chainId: undefined, + isConnected: false, + isConnecting: false, + })), + useChainId: vi.fn(() => 1), + useSwitchChain: vi.fn(() => ({ isPending: false, switchChain: mockSwitchChain })), + usePublicClient: vi.fn(() => undefined), + useWalletClient: vi.fn(() => ({ data: undefined })), + useBalance: vi.fn(() => ({ data: undefined })), + useDisconnect: vi.fn(() => ({ disconnect: mockDisconnect })), +})) + +import * as wagmi from 'wagmi' + +type MockAccount = ReturnType +type MockSwitchChain = ReturnType + +describe('useWeb3Status', () => { + it('returns disconnected state when no wallet connected', () => { + const { result } = renderHook(() => useWeb3Status()) + expect(result.current.isWalletConnected).toBe(false) + expect(result.current.address).toBeUndefined() + expect(result.current.walletChainId).toBeUndefined() + }) + + it('returns connected state with wallet address', () => { + const mock = { + address: '0xabc123' as `0x${string}`, + chainId: 1, + isConnected: true, + isConnecting: false, + } as unknown as MockAccount + vi.mocked(wagmi.useAccount).mockReturnValueOnce(mock) + const { result } = renderHook(() => useWeb3Status()) + expect(result.current.isWalletConnected).toBe(true) + expect(result.current.address).toBe('0xabc123') + }) + + it('sets isWalletSynced true when wallet chainId matches app chainId', () => { + const mock = { + address: '0xabc123' as `0x${string}`, + chainId: 1, + isConnected: true, + isConnecting: false, + } as unknown as MockAccount + vi.mocked(wagmi.useAccount).mockReturnValueOnce(mock) + vi.mocked(wagmi.useChainId).mockReturnValueOnce(1) + const { result } = renderHook(() => useWeb3Status()) + expect(result.current.isWalletSynced).toBe(true) + }) + + it('sets isWalletSynced false when wallet chainId differs from app chainId', () => { + const mock = { + address: '0xabc123' as `0x${string}`, + chainId: 137, + isConnected: true, + isConnecting: false, + } as unknown as MockAccount + vi.mocked(wagmi.useAccount).mockReturnValueOnce(mock) + vi.mocked(wagmi.useChainId).mockReturnValueOnce(1) + const { result } = renderHook(() => useWeb3Status()) + expect(result.current.isWalletSynced).toBe(false) + }) + + it('sets switchingChain when useSwitchChain is pending', () => { + const mock = { isPending: true, switchChain: mockSwitchChain } as unknown as MockSwitchChain + vi.mocked(wagmi.useSwitchChain).mockReturnValueOnce(mock) + const { result } = renderHook(() => useWeb3Status()) + expect(result.current.switchingChain).toBe(true) + }) + + it('exposes disconnect function', () => { + const { result } = renderHook(() => useWeb3Status()) + result.current.disconnect() + expect(mockDisconnect).toHaveBeenCalled() + }) + + it('calls switchChain with chainId when switchChain action is invoked', () => { + const { result } = renderHook(() => useWeb3Status()) + result.current.switchChain(137) + expect(mockSwitchChain).toHaveBeenCalledWith({ chainId: 137 }) + }) + + it('exposes appChainId from useChainId', () => { + vi.mocked(wagmi.useChainId).mockReturnValueOnce(42161) + const { result } = renderHook(() => useWeb3Status()) + expect(result.current.appChainId).toBe(42161) + }) +}) + +describe('useWeb3StatusConnected', () => { + it('throws when wallet is not connected', () => { + expect(() => renderHook(() => useWeb3StatusConnected())).toThrow( + 'Use useWeb3StatusConnected only when a wallet is connected', + ) + }) + + it('returns status when wallet is connected', () => { + const mock = { + address: '0xdeadbeef' as `0x${string}`, + chainId: 1, + isConnected: true, + isConnecting: false, + } as unknown as MockAccount + vi.mocked(wagmi.useAccount).mockReturnValue(mock) + const { result } = renderHook(() => useWeb3StatusConnected()) + expect(result.current.isWalletConnected).toBe(true) + }) +}) From d6ced762232baf4ef29715a4c381ee9732496990 Mon Sep 17 00:00:00 2001 From: Gabito Esmiapodo <4015436+gabitoesmiapodo@users.noreply.github.com> Date: Mon, 23 Mar 2026 18:59:40 -0300 Subject: [PATCH 08/16] test: address review feedback on Tier 3 hook test suite - Add beforeEach mock clearing in useWeb3Status to prevent shared mock call history leaking between tests - Switch mockReturnValue to mockReturnValueOnce for the connected test in useWeb3StatusConnected (hook calls useWeb3Status twice internally) - Add beforeEach mockReadContract.mockClear() in useErc20Balance to prevent cross-test false negatives on not.toHaveBeenCalled assertions --- src/hooks/useErc20Balance.test.ts | 6 +++++- src/hooks/useWeb3Status.test.ts | 10 ++++++++-- 2 files changed, 13 insertions(+), 3 deletions(-) diff --git a/src/hooks/useErc20Balance.test.ts b/src/hooks/useErc20Balance.test.ts index 28ff74ee..04b3f033 100644 --- a/src/hooks/useErc20Balance.test.ts +++ b/src/hooks/useErc20Balance.test.ts @@ -4,7 +4,7 @@ import { renderHook, waitFor } from '@testing-library/react' import type { ReactNode } from 'react' import { createElement } from 'react' import { zeroAddress } from 'viem' -import { describe, expect, it, vi } from 'vitest' +import { beforeEach, describe, expect, it, vi } from 'vitest' import { useErc20Balance } from './useErc20Balance' const mockReadContract = vi.fn() @@ -37,6 +37,10 @@ const mockToken: Token = { const walletAddress = '0x71C7656EC7ab88b098defB751B7401B5f6d8976F' as `0x${string}` describe('useErc20Balance', () => { + beforeEach(() => { + mockReadContract.mockClear() + }) + it('returns undefined balance when address is missing', () => { const { result } = renderHook(() => useErc20Balance({ token: mockToken }), { wrapper }) expect(result.current.balance).toBeUndefined() diff --git a/src/hooks/useWeb3Status.test.ts b/src/hooks/useWeb3Status.test.ts index 83e2a208..ab31f452 100644 --- a/src/hooks/useWeb3Status.test.ts +++ b/src/hooks/useWeb3Status.test.ts @@ -1,5 +1,5 @@ import { renderHook } from '@testing-library/react' -import { describe, expect, it, vi } from 'vitest' +import { beforeEach, describe, expect, it, vi } from 'vitest' import { useWeb3Status, useWeb3StatusConnected } from './useWeb3Status' const mockDisconnect = vi.fn() @@ -26,6 +26,11 @@ type MockAccount = ReturnType type MockSwitchChain = ReturnType describe('useWeb3Status', () => { + beforeEach(() => { + mockDisconnect.mockClear() + mockSwitchChain.mockClear() + }) + it('returns disconnected state when no wallet connected', () => { const { result } = renderHook(() => useWeb3Status()) expect(result.current.isWalletConnected).toBe(false) @@ -112,7 +117,8 @@ describe('useWeb3StatusConnected', () => { isConnected: true, isConnecting: false, } as unknown as MockAccount - vi.mocked(wagmi.useAccount).mockReturnValue(mock) + // useWeb3StatusConnected calls useWeb3Status twice; both calls must see connected state + vi.mocked(wagmi.useAccount).mockReturnValueOnce(mock).mockReturnValueOnce(mock) const { result } = renderHook(() => useWeb3StatusConnected()) expect(result.current.isWalletConnected).toBe(true) }) From 7bda650bb0b4e3e344d336d8c2009b2f88534b14 Mon Sep 17 00:00:00 2001 From: Gabito Esmiapodo <4015436+gabitoesmiapodo@users.noreply.github.com> Date: Mon, 23 Mar 2026 17:39:28 -0300 Subject: [PATCH 09/16] 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) --- setupTests.ts | 9 +- .../sharedComponents/SwitchNetwork.test.tsx | 115 ++++++++++++++ .../sharedComponents/TokenLogo.test.tsx | 75 +++++++++ .../TransactionButton.test.tsx | 142 ++++++++++++++++++ .../WalletStatusVerifier.test.tsx | 121 +++++++++++++++ src/utils/suspenseWrapper.test.tsx | 115 ++++++++++++++ 6 files changed, 576 insertions(+), 1 deletion(-) create mode 100644 src/components/sharedComponents/SwitchNetwork.test.tsx create mode 100644 src/components/sharedComponents/TokenLogo.test.tsx create mode 100644 src/components/sharedComponents/TransactionButton.test.tsx create mode 100644 src/components/sharedComponents/WalletStatusVerifier.test.tsx create mode 100644 src/utils/suspenseWrapper.test.tsx diff --git a/setupTests.ts b/setupTests.ts index a55b20e6..f70a3b90 100644 --- a/setupTests.ts +++ b/setupTests.ts @@ -1,9 +1,16 @@ import * as matchers from '@testing-library/jest-dom/matchers' import { cleanup } from '@testing-library/react' -import { afterEach, expect } from 'vitest' +import { afterEach, expect, vi } from 'vitest' expect.extend(matchers) +// ResizeObserver is not implemented in jsdom but required by @floating-ui (Chakra menus/popovers) +global.ResizeObserver = vi.fn().mockImplementation(() => ({ + observe: vi.fn(), + unobserve: vi.fn(), + disconnect: vi.fn(), +})) + 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..d15ff916 --- /dev/null +++ b/src/components/sharedComponents/TransactionButton.test.tsx @@ -0,0 +1,142 @@ +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()) + // Return receipt immediately so effect fires once isPending=true + vi.mocked(wagmiModule.useWaitForTransactionReceipt).mockReturnValue({ + data: mockReceipt, + } 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..a130d244 --- /dev/null +++ b/src/utils/suspenseWrapper.test.tsx @@ -0,0 +1,115 @@ +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 +beforeEach(() => { + vi.spyOn(console, 'error').mockImplementation(() => {}) +}) +afterEach(() => { + vi.restoreAllMocks() +}) + +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() + }) + }) +}) From 7b00d3b6f13f87fe697808842d0a1e5ca23bb376 Mon Sep 17 00:00:00 2001 From: Gabito Esmiapodo <4015436+gabitoesmiapodo@users.noreply.github.com> Date: Mon, 23 Mar 2026 20:57:36 -0300 Subject: [PATCH 10/16] test: address review feedback on Tier 4 component tests MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - setupTests.ts: replace vi.fn() ResizeObserver with a real class guarded by a conditional check — vi.restoreAllMocks() can no longer clear it - suspenseWrapper.test.tsx: restore only the console.error spy in afterEach instead of vi.restoreAllMocks() which would wipe the ResizeObserver polyfill - TransactionButton.test.tsx: make useWaitForTransactionReceipt mock hash-aware so it only returns the receipt when called with the expected hash --- setupTests.ts | 21 ++++++++++++------- .../TransactionButton.test.tsx | 12 +++++++---- src/utils/suspenseWrapper.test.tsx | 9 +++++--- 3 files changed, 28 insertions(+), 14 deletions(-) diff --git a/setupTests.ts b/setupTests.ts index f70a3b90..a9d13f77 100644 --- a/setupTests.ts +++ b/setupTests.ts @@ -1,15 +1,22 @@ import * as matchers from '@testing-library/jest-dom/matchers' import { cleanup } from '@testing-library/react' -import { afterEach, expect, vi } from 'vitest' +import { afterEach, expect } from 'vitest' expect.extend(matchers) -// ResizeObserver is not implemented in jsdom but required by @floating-ui (Chakra menus/popovers) -global.ResizeObserver = vi.fn().mockImplementation(() => ({ - observe: vi.fn(), - unobserve: vi.fn(), - disconnect: vi.fn(), -})) +// 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/TransactionButton.test.tsx b/src/components/sharedComponents/TransactionButton.test.tsx index d15ff916..74e4686d 100644 --- a/src/components/sharedComponents/TransactionButton.test.tsx +++ b/src/components/sharedComponents/TransactionButton.test.tsx @@ -123,10 +123,14 @@ describe('TransactionButton', () => { const onMined = vi.fn() vi.mocked(useWeb3StatusModule.useWeb3Status).mockReturnValue(connectedStatus()) - // Return receipt immediately so effect fires once isPending=true - vi.mocked(wagmiModule.useWaitForTransactionReceipt).mockReturnValue({ - data: mockReceipt, - } as ReturnType) + // 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}`), diff --git a/src/utils/suspenseWrapper.test.tsx b/src/utils/suspenseWrapper.test.tsx index a130d244..bd529c57 100644 --- a/src/utils/suspenseWrapper.test.tsx +++ b/src/utils/suspenseWrapper.test.tsx @@ -7,12 +7,15 @@ import { withSuspense, withSuspenseAndRetry } from './suspenseWrapper' const system = createSystem(defaultConfig) -// Silence expected React error boundary console.errors +// 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(() => { - vi.spyOn(console, 'error').mockImplementation(() => {}) + consoleErrorSpy = vi.spyOn(console, 'error').mockImplementation(() => {}) }) afterEach(() => { - vi.restoreAllMocks() + consoleErrorSpy.mockRestore() }) function wrap(ui: ReactNode, withQuery = false) { From 09e9de14ccff9cda4aae8f0be2e93066d61e5182 Mon Sep 17 00:00:00 2001 From: Gabito Esmiapodo <4015436+gabitoesmiapodo@users.noreply.github.com> Date: Mon, 23 Mar 2026 17:46:21 -0300 Subject: [PATCH 11/16] test: add demo page smoke tests (Tier 5) Smoke tests for all demo page components to verify they render without crashing. Mocks external Web3 deps at module level. New test files: - home/index.test.tsx: Home renders Welcome + Examples sections - NotFound404.test.tsx: 404 page with mocked useNavigate - demos/ConnectWallet/index.test.tsx: mocked ConnectWalletButton - demos/EnsName/index.test.tsx: mocked useEnsName - demos/HashHandling/index.test.tsx: mocked useWeb3Status + detectHash - demos/SignMessage/index.test.tsx: shows fallback when wallet absent - demos/SwitchNetwork/index.test.tsx: shows fallback when wallet absent - demos/TransactionButton/index.test.tsx: shows fallback when wallet absent - demos/TokenDropdown/index.test.tsx: mocked BaseTokenDropdown - demos/TokenInput/index.test.tsx: mocked useTokenLists + useTokenSearch Also adds .env.test and src/test-utils.tsx (shared test utilities). --- .../pageComponents/NotFound404.test.tsx | 22 ++++++++ .../demos/ConnectWallet/index.test.tsx | 17 +++++++ .../Examples/demos/EnsName/index.test.tsx | 20 ++++++++ .../demos/HashHandling/index.test.tsx | 24 +++++++++ .../Examples/demos/SignMessage/index.test.tsx | 27 ++++++++++ .../demos/SwitchNetwork/index.test.tsx | 23 +++++++++ .../demos/TokenDropdown/index.test.tsx | 20 ++++++++ .../Examples/demos/TokenInput/index.test.tsx | 51 +++++++++++++++++++ .../demos/TransactionButton/index.test.tsx | 27 ++++++++++ .../pageComponents/home/index.test.tsx | 27 ++++++++++ 10 files changed, 258 insertions(+) create mode 100644 src/components/pageComponents/NotFound404.test.tsx create mode 100644 src/components/pageComponents/home/Examples/demos/ConnectWallet/index.test.tsx create mode 100644 src/components/pageComponents/home/Examples/demos/EnsName/index.test.tsx create mode 100644 src/components/pageComponents/home/Examples/demos/HashHandling/index.test.tsx create mode 100644 src/components/pageComponents/home/Examples/demos/SignMessage/index.test.tsx create mode 100644 src/components/pageComponents/home/Examples/demos/SwitchNetwork/index.test.tsx create mode 100644 src/components/pageComponents/home/Examples/demos/TokenDropdown/index.test.tsx create mode 100644 src/components/pageComponents/home/Examples/demos/TokenInput/index.test.tsx create mode 100644 src/components/pageComponents/home/Examples/demos/TransactionButton/index.test.tsx create mode 100644 src/components/pageComponents/home/index.test.tsx diff --git a/src/components/pageComponents/NotFound404.test.tsx b/src/components/pageComponents/NotFound404.test.tsx new file mode 100644 index 00000000..6ce87039 --- /dev/null +++ b/src/components/pageComponents/NotFound404.test.tsx @@ -0,0 +1,22 @@ +import { ChakraProvider, createSystem, defaultConfig } from '@chakra-ui/react' +import { render, screen } from '@testing-library/react' +import { describe, expect, it, vi } from 'vitest' +import NotFound404 from './NotFound404' + +const system = createSystem(defaultConfig) + +vi.mock('@tanstack/react-router', () => ({ + useNavigate: vi.fn(() => vi.fn()), +})) + +describe('NotFound404', () => { + it('renders 404 title and message', () => { + render( + + + , + ) + expect(screen.getByText('404 - Not Found')).toBeDefined() + expect(screen.getByRole('button', { name: 'Home' })).toBeDefined() + }) +}) diff --git a/src/components/pageComponents/home/Examples/demos/ConnectWallet/index.test.tsx b/src/components/pageComponents/home/Examples/demos/ConnectWallet/index.test.tsx new file mode 100644 index 00000000..ab37345f --- /dev/null +++ b/src/components/pageComponents/home/Examples/demos/ConnectWallet/index.test.tsx @@ -0,0 +1,17 @@ +import { ChakraProvider, createSystem, defaultConfig } from '@chakra-ui/react' +import { render, screen } from '@testing-library/react' +import { describe, expect, it, vi } from 'vitest' +import connectWallet from './index' + +const system = createSystem(defaultConfig) + +vi.mock('@/src/providers/Web3Provider', () => ({ + ConnectWalletButton: () => , +})) + +describe('ConnectWallet demo', () => { + it('renders the connect wallet button', () => { + render({connectWallet.demo}) + expect(screen.getByRole('button', { name: 'Connect Wallet' })).toBeDefined() + }) +}) diff --git a/src/components/pageComponents/home/Examples/demos/EnsName/index.test.tsx b/src/components/pageComponents/home/Examples/demos/EnsName/index.test.tsx new file mode 100644 index 00000000..2463d875 --- /dev/null +++ b/src/components/pageComponents/home/Examples/demos/EnsName/index.test.tsx @@ -0,0 +1,20 @@ +import { ChakraProvider, createSystem, defaultConfig } from '@chakra-ui/react' +import { render, screen } from '@testing-library/react' +import { describe, expect, it, vi } from 'vitest' +import ensName from './index' + +const system = createSystem(defaultConfig) + +vi.mock('wagmi', () => ({ + useEnsName: vi.fn(() => ({ data: undefined, error: undefined, status: 'pending' })), +})) + +describe('EnsName demo', () => { + it('renders the ENS name search interface', () => { + render({ensName.demo}) + expect(screen.getByText('Find ENS name')).toBeDefined() + expect( + screen.getByPlaceholderText('Enter an address or select one from the dropdown'), + ).toBeDefined() + }) +}) diff --git a/src/components/pageComponents/home/Examples/demos/HashHandling/index.test.tsx b/src/components/pageComponents/home/Examples/demos/HashHandling/index.test.tsx new file mode 100644 index 00000000..938c0b6e --- /dev/null +++ b/src/components/pageComponents/home/Examples/demos/HashHandling/index.test.tsx @@ -0,0 +1,24 @@ +import { ChakraProvider, createSystem, defaultConfig } from '@chakra-ui/react' +import { render, screen } from '@testing-library/react' +import { describe, expect, it, vi } from 'vitest' +import hashHandling from './index' + +const system = createSystem(defaultConfig) + +vi.mock('@/src/hooks/useWeb3Status', () => ({ + useWeb3Status: vi.fn(() => ({ + isWalletConnected: false, + walletChainId: undefined, + })), +})) + +vi.mock('@/src/utils/hash', () => ({ + detectHash: vi.fn(() => Promise.resolve(null)), +})) + +describe('HashHandling demo', () => { + it('renders the hash input field', () => { + render({hashHandling.demo}) + expect(screen.getByPlaceholderText(/address|hash/i)).toBeDefined() + }) +}) diff --git a/src/components/pageComponents/home/Examples/demos/SignMessage/index.test.tsx b/src/components/pageComponents/home/Examples/demos/SignMessage/index.test.tsx new file mode 100644 index 00000000..d673002c --- /dev/null +++ b/src/components/pageComponents/home/Examples/demos/SignMessage/index.test.tsx @@ -0,0 +1,27 @@ +import { ChakraProvider, createSystem, defaultConfig } from '@chakra-ui/react' +import { render, screen } from '@testing-library/react' +import { describe, expect, it, vi } from 'vitest' +import signMessage from './index' + +const system = createSystem(defaultConfig) + +vi.mock('@/src/hooks/useWeb3Status', () => ({ + useWeb3Status: vi.fn(() => ({ + isWalletConnected: false, + isWalletSynced: false, + walletChainId: undefined, + appChainId: 11155420, + switchChain: vi.fn(), + })), +})) + +vi.mock('@/src/providers/Web3Provider', () => ({ + ConnectWalletButton: () => , +})) + +describe('SignMessage demo', () => { + it('renders connect wallet fallback when wallet not connected', () => { + render({signMessage.demo}) + expect(screen.getByRole('button', { name: 'Connect Wallet' })).toBeDefined() + }) +}) diff --git a/src/components/pageComponents/home/Examples/demos/SwitchNetwork/index.test.tsx b/src/components/pageComponents/home/Examples/demos/SwitchNetwork/index.test.tsx new file mode 100644 index 00000000..7b93bb7b --- /dev/null +++ b/src/components/pageComponents/home/Examples/demos/SwitchNetwork/index.test.tsx @@ -0,0 +1,23 @@ +import { ChakraProvider, createSystem, defaultConfig } from '@chakra-ui/react' +import { render, screen } from '@testing-library/react' +import { describe, expect, it, vi } from 'vitest' +import switchNetwork from './index' + +const system = createSystem(defaultConfig) + +vi.mock('@/src/hooks/useWeb3Status', () => ({ + useWeb3Status: vi.fn(() => ({ + isWalletConnected: false, + })), +})) + +vi.mock('@/src/providers/Web3Provider', () => ({ + ConnectWalletButton: () => , +})) + +describe('SwitchNetwork demo', () => { + it('renders connect wallet button when wallet not connected', () => { + render({switchNetwork.demo}) + expect(screen.getByRole('button', { name: 'Connect Wallet' })).toBeDefined() + }) +}) diff --git a/src/components/pageComponents/home/Examples/demos/TokenDropdown/index.test.tsx b/src/components/pageComponents/home/Examples/demos/TokenDropdown/index.test.tsx new file mode 100644 index 00000000..ade29b7c --- /dev/null +++ b/src/components/pageComponents/home/Examples/demos/TokenDropdown/index.test.tsx @@ -0,0 +1,20 @@ +import { ChakraProvider, createSystem, defaultConfig } from '@chakra-ui/react' +import { render, screen } from '@testing-library/react' +import { describe, expect, it, vi } from 'vitest' +import tokenDropdown from './index' + +const system = createSystem(defaultConfig) + +// Mock the shared component to avoid its deep dependency chain +// (TokenSelect uses withSuspenseAndRetry, useTokenLists, useTokens, etc.) +vi.mock('@/src/components/sharedComponents/TokenDropdown', () => ({ + default: () =>
Token Dropdown
, +})) + +describe('TokenDropdown demo', () => { + it('renders the token dropdown container', () => { + render({tokenDropdown.demo}) + expect(screen.getByText('Search and select a token')).toBeDefined() + expect(screen.getByTestId('token-dropdown-mock')).toBeDefined() + }) +}) diff --git a/src/components/pageComponents/home/Examples/demos/TokenInput/index.test.tsx b/src/components/pageComponents/home/Examples/demos/TokenInput/index.test.tsx new file mode 100644 index 00000000..18930d6d --- /dev/null +++ b/src/components/pageComponents/home/Examples/demos/TokenInput/index.test.tsx @@ -0,0 +1,51 @@ +import { ChakraProvider, createSystem, defaultConfig } from '@chakra-ui/react' +import { QueryClient, QueryClientProvider } from '@tanstack/react-query' +import { render, screen } from '@testing-library/react' +import { describe, expect, it, vi } from 'vitest' +import tokenInput from './index' + +const system = createSystem(defaultConfig) + +vi.mock('@/src/hooks/useWeb3Status', () => ({ + useWeb3Status: vi.fn(() => ({ + isWalletConnected: false, + })), +})) + +vi.mock('@/src/hooks/useTokenLists', () => ({ + useTokenLists: vi.fn(() => ({ + tokens: [], + tokensByChainId: {}, + tokensByAddress: {}, + tokensBySymbol: {}, + })), +})) + +vi.mock('@/src/hooks/useTokenSearch', () => ({ + useTokenSearch: vi.fn(() => ({ + searchResult: [], + })), +})) + +vi.mock('@/src/components/sharedComponents/TokenInput/useTokenInput', () => ({ + useTokenInput: vi.fn(() => ({ + value: '', + token: undefined, + error: undefined, + onChange: vi.fn(), + onTokenSelect: vi.fn(), + })), +})) + +describe('TokenInput demo', () => { + it('renders the token input container', () => { + const queryClient = new QueryClient({ defaultOptions: { queries: { retry: false } } }) + render( + + {tokenInput.demo} + , + ) + // The mode dropdown should be visible + expect(screen.getByText('Single token')).toBeDefined() + }) +}) diff --git a/src/components/pageComponents/home/Examples/demos/TransactionButton/index.test.tsx b/src/components/pageComponents/home/Examples/demos/TransactionButton/index.test.tsx new file mode 100644 index 00000000..6304186b --- /dev/null +++ b/src/components/pageComponents/home/Examples/demos/TransactionButton/index.test.tsx @@ -0,0 +1,27 @@ +import { ChakraProvider, createSystem, defaultConfig } from '@chakra-ui/react' +import { render, screen } from '@testing-library/react' +import { describe, expect, it, vi } from 'vitest' +import transactionButton from './index' + +const system = createSystem(defaultConfig) + +vi.mock('@/src/hooks/useWeb3Status', () => ({ + useWeb3Status: vi.fn(() => ({ + isWalletConnected: false, + isWalletSynced: false, + walletChainId: undefined, + appChainId: 11155420, + switchChain: vi.fn(), + })), +})) + +vi.mock('@/src/providers/Web3Provider', () => ({ + ConnectWalletButton: () => , +})) + +describe('TransactionButton demo', () => { + it('renders connect wallet fallback when wallet not connected', () => { + render({transactionButton.demo}) + expect(screen.getByRole('button', { name: 'Connect Wallet' })).toBeDefined() + }) +}) diff --git a/src/components/pageComponents/home/index.test.tsx b/src/components/pageComponents/home/index.test.tsx new file mode 100644 index 00000000..b73a7e48 --- /dev/null +++ b/src/components/pageComponents/home/index.test.tsx @@ -0,0 +1,27 @@ +import { ChakraProvider, createSystem, defaultConfig } from '@chakra-ui/react' +import { render, screen } from '@testing-library/react' +import { describe, expect, it, vi } from 'vitest' +import { Home } from './index' + +const system = createSystem(defaultConfig) + +// Mock sub-components that pull in Web3 dependencies to keep this a pure structural test +vi.mock('@/src/components/pageComponents/home/Examples', () => ({ + default: () =>
Examples
, +})) + +vi.mock('@/src/components/pageComponents/home/Welcome', () => ({ + default: () =>
Welcome
, +})) + +describe('Home', () => { + it('renders Welcome and Examples sections', () => { + render( + + + , + ) + expect(screen.getByTestId('welcome')).toBeDefined() + expect(screen.getByTestId('examples')).toBeDefined() + }) +}) From 4fe5f28ab4b6b44576585a31be60df45dcceebce Mon Sep 17 00:00:00 2001 From: Gabito Esmiapodo <4015436+gabitoesmiapodo@users.noreply.github.com> Date: Mon, 23 Mar 2026 19:00:21 -0300 Subject: [PATCH 12/16] test: fix detectHash mock in HashHandling smoke test The module exports detectHash as a default export; the mock only provided a named export so HashInput would receive undefined and throw on invocation. Added both default and named exports to the mock factory. --- .../home/Examples/demos/HashHandling/index.test.tsx | 10 +++++++--- 1 file changed, 7 insertions(+), 3 deletions(-) diff --git a/src/components/pageComponents/home/Examples/demos/HashHandling/index.test.tsx b/src/components/pageComponents/home/Examples/demos/HashHandling/index.test.tsx index 938c0b6e..90fc9c0f 100644 --- a/src/components/pageComponents/home/Examples/demos/HashHandling/index.test.tsx +++ b/src/components/pageComponents/home/Examples/demos/HashHandling/index.test.tsx @@ -12,9 +12,13 @@ vi.mock('@/src/hooks/useWeb3Status', () => ({ })), })) -vi.mock('@/src/utils/hash', () => ({ - detectHash: vi.fn(() => Promise.resolve(null)), -})) +vi.mock('@/src/utils/hash', () => { + const mockFn = vi.fn(() => Promise.resolve(null)) + return { + default: mockFn, + detectHash: mockFn, + } +}) describe('HashHandling demo', () => { it('renders the hash input field', () => { From b0fe779caf0ba2e2cbcd46a25874ab75044b39af Mon Sep 17 00:00:00 2001 From: Gabito Esmiapodo <4015436+gabitoesmiapodo@users.noreply.github.com> Date: Mon, 23 Mar 2026 20:59:27 -0300 Subject: [PATCH 13/16] test: address review feedback on Tier 5 demo smoke tests - home/index.test.tsx: use renderWithProviders from test-utils - TransactionButton demo: use createMockWeb3Status for complete hook shape - TokenInput demo: fix useTokenInput mock to match the real hook return (amount/setAmount/amountError/balance/isLoadingBalance/selectedToken/setTokenSelected) and use renderWithProviders + createMockWeb3Status helpers --- .../Examples/demos/TokenInput/index.test.tsx | 30 +++++++++---------- .../demos/TransactionButton/index.test.tsx | 16 +++------- .../pageComponents/home/index.test.tsx | 12 ++------ 3 files changed, 21 insertions(+), 37 deletions(-) diff --git a/src/components/pageComponents/home/Examples/demos/TokenInput/index.test.tsx b/src/components/pageComponents/home/Examples/demos/TokenInput/index.test.tsx index 18930d6d..9e86b8b7 100644 --- a/src/components/pageComponents/home/Examples/demos/TokenInput/index.test.tsx +++ b/src/components/pageComponents/home/Examples/demos/TokenInput/index.test.tsx @@ -1,15 +1,11 @@ -import { ChakraProvider, createSystem, defaultConfig } from '@chakra-ui/react' +import { createMockWeb3Status, renderWithProviders } from '@/src/test-utils' import { QueryClient, QueryClientProvider } from '@tanstack/react-query' -import { render, screen } from '@testing-library/react' +import { screen } from '@testing-library/react' import { describe, expect, it, vi } from 'vitest' import tokenInput from './index' -const system = createSystem(defaultConfig) - vi.mock('@/src/hooks/useWeb3Status', () => ({ - useWeb3Status: vi.fn(() => ({ - isWalletConnected: false, - })), + useWeb3Status: vi.fn(() => createMockWeb3Status()), })) vi.mock('@/src/hooks/useTokenLists', () => ({ @@ -29,21 +25,23 @@ vi.mock('@/src/hooks/useTokenSearch', () => ({ vi.mock('@/src/components/sharedComponents/TokenInput/useTokenInput', () => ({ useTokenInput: vi.fn(() => ({ - value: '', - token: undefined, - error: undefined, - onChange: vi.fn(), - onTokenSelect: vi.fn(), + amount: 0n, + setAmount: vi.fn(), + amountError: null, + setAmountError: vi.fn(), + balance: 0n, + balanceError: null, + isLoadingBalance: false, + selectedToken: undefined, + setTokenSelected: vi.fn(), })), })) describe('TokenInput demo', () => { it('renders the token input container', () => { const queryClient = new QueryClient({ defaultOptions: { queries: { retry: false } } }) - render( - - {tokenInput.demo} - , + renderWithProviders( + {tokenInput.demo}, ) // The mode dropdown should be visible expect(screen.getByText('Single token')).toBeDefined() diff --git a/src/components/pageComponents/home/Examples/demos/TransactionButton/index.test.tsx b/src/components/pageComponents/home/Examples/demos/TransactionButton/index.test.tsx index 6304186b..1d7d3be8 100644 --- a/src/components/pageComponents/home/Examples/demos/TransactionButton/index.test.tsx +++ b/src/components/pageComponents/home/Examples/demos/TransactionButton/index.test.tsx @@ -1,18 +1,10 @@ -import { ChakraProvider, createSystem, defaultConfig } from '@chakra-ui/react' -import { render, screen } from '@testing-library/react' +import { createMockWeb3Status, renderWithProviders } from '@/src/test-utils' +import { screen } from '@testing-library/react' import { describe, expect, it, vi } from 'vitest' import transactionButton from './index' -const system = createSystem(defaultConfig) - vi.mock('@/src/hooks/useWeb3Status', () => ({ - useWeb3Status: vi.fn(() => ({ - isWalletConnected: false, - isWalletSynced: false, - walletChainId: undefined, - appChainId: 11155420, - switchChain: vi.fn(), - })), + useWeb3Status: vi.fn(() => createMockWeb3Status({ appChainId: 11155420 })), })) vi.mock('@/src/providers/Web3Provider', () => ({ @@ -21,7 +13,7 @@ vi.mock('@/src/providers/Web3Provider', () => ({ describe('TransactionButton demo', () => { it('renders connect wallet fallback when wallet not connected', () => { - render({transactionButton.demo}) + renderWithProviders(transactionButton.demo) expect(screen.getByRole('button', { name: 'Connect Wallet' })).toBeDefined() }) }) diff --git a/src/components/pageComponents/home/index.test.tsx b/src/components/pageComponents/home/index.test.tsx index b73a7e48..d498d8a1 100644 --- a/src/components/pageComponents/home/index.test.tsx +++ b/src/components/pageComponents/home/index.test.tsx @@ -1,10 +1,8 @@ -import { ChakraProvider, createSystem, defaultConfig } from '@chakra-ui/react' -import { render, screen } from '@testing-library/react' +import { renderWithProviders } from '@/src/test-utils' +import { screen } from '@testing-library/react' import { describe, expect, it, vi } from 'vitest' import { Home } from './index' -const system = createSystem(defaultConfig) - // Mock sub-components that pull in Web3 dependencies to keep this a pure structural test vi.mock('@/src/components/pageComponents/home/Examples', () => ({ default: () =>
Examples
, @@ -16,11 +14,7 @@ vi.mock('@/src/components/pageComponents/home/Welcome', () => ({ describe('Home', () => { it('renders Welcome and Examples sections', () => { - render( - - - , - ) + renderWithProviders() expect(screen.getByTestId('welcome')).toBeDefined() expect(screen.getByTestId('examples')).toBeDefined() }) From a412c4f52ff176c9173efdbc9c26ae2626afdbb0 Mon Sep 17 00:00:00 2001 From: Gabito Esmiapodo <4015436+gabitoesmiapodo@users.noreply.github.com> Date: Wed, 25 Mar 2026 15:14:39 -0300 Subject: [PATCH 14/16] test: fix redundant cases in isNativeToken test suite --- src/utils/address.test.ts | 11 +---------- 1 file changed, 1 insertion(+), 10 deletions(-) diff --git a/src/utils/address.test.ts b/src/utils/address.test.ts index 754488d8..49c079a9 100644 --- a/src/utils/address.test.ts +++ b/src/utils/address.test.ts @@ -4,7 +4,7 @@ import { describe, expect, it, vi } from 'vitest' // Mock env before importing isNativeToken so the module sees the mock vi.mock('@/src/env', () => ({ env: { - PUBLIC_NATIVE_TOKEN_ADDRESS: zeroAddress.toLowerCase(), + PUBLIC_NATIVE_TOKEN_ADDRESS: zeroAddress, }, })) @@ -15,10 +15,6 @@ describe('isNativeToken', () => { expect(isNativeToken(zeroAddress)).toBe(true) }) - it('returns true for the zero address in lowercase', () => { - expect(isNativeToken(zeroAddress.toLowerCase())).toBe(true) - }) - it('returns true for the zero address string literal', () => { // zeroAddress is already lowercase; the literal string is identical — testing the exact value expect(isNativeToken('0x0000000000000000000000000000000000000000')).toBe(true) @@ -27,9 +23,4 @@ describe('isNativeToken', () => { it('returns false for a regular ERC20 contract address', () => { expect(isNativeToken('0x71C7656EC7ab88b098defB751B7401B5f6d8976F')).toBe(false) }) - - it('comparison is case-insensitive', () => { - // Both upper and lower case should match the native token (zero address) - expect(isNativeToken('0X0000000000000000000000000000000000000000')).toBe(true) - }) }) From 990a656c71a30ec6236775b488cb6d2ab7a5d087 Mon Sep 17 00:00:00 2001 From: Gabito Esmiapodo <4015436+gabitoesmiapodo@users.noreply.github.com> Date: Wed, 25 Mar 2026 15:18:19 -0300 Subject: [PATCH 15/16] chore: ignore .worktrees directory --- .gitignore | 1 + 1 file changed, 1 insertion(+) diff --git a/.gitignore b/.gitignore index be85071d..9203123b 100644 --- a/.gitignore +++ b/.gitignore @@ -43,3 +43,4 @@ yarn-debug.log* yarn-error.log* pnpm-debug.log* lerna-debug.log* +.worktrees From 5861213a33118758d63e0f3cf88879f2eb509e5b Mon Sep 17 00:00:00 2001 From: Gabito Esmiapodo <4015436+gabitoesmiapodo@users.noreply.github.com> Date: Wed, 25 Mar 2026 15:45:33 -0300 Subject: [PATCH 16/16] refactor(tests): use Address type from viem instead of template literal --- src/hooks/useWeb3Status.test.ts | 9 +++++---- 1 file changed, 5 insertions(+), 4 deletions(-) diff --git a/src/hooks/useWeb3Status.test.ts b/src/hooks/useWeb3Status.test.ts index ab31f452..1010ea0b 100644 --- a/src/hooks/useWeb3Status.test.ts +++ b/src/hooks/useWeb3Status.test.ts @@ -1,4 +1,5 @@ import { renderHook } from '@testing-library/react' +import type { Address } from 'viem' import { beforeEach, describe, expect, it, vi } from 'vitest' import { useWeb3Status, useWeb3StatusConnected } from './useWeb3Status' @@ -40,7 +41,7 @@ describe('useWeb3Status', () => { it('returns connected state with wallet address', () => { const mock = { - address: '0xabc123' as `0x${string}`, + address: '0xabc123' as Address, chainId: 1, isConnected: true, isConnecting: false, @@ -53,7 +54,7 @@ describe('useWeb3Status', () => { it('sets isWalletSynced true when wallet chainId matches app chainId', () => { const mock = { - address: '0xabc123' as `0x${string}`, + address: '0xabc123' as Address, chainId: 1, isConnected: true, isConnecting: false, @@ -66,7 +67,7 @@ describe('useWeb3Status', () => { it('sets isWalletSynced false when wallet chainId differs from app chainId', () => { const mock = { - address: '0xabc123' as `0x${string}`, + address: '0xabc123' as Address, chainId: 137, isConnected: true, isConnecting: false, @@ -112,7 +113,7 @@ describe('useWeb3StatusConnected', () => { it('returns status when wallet is connected', () => { const mock = { - address: '0xdeadbeef' as `0x${string}`, + address: '0xdeadbeef' as Address, chainId: 1, isConnected: true, isConnecting: false,