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: () => ,
+}))
+
+vi.mock('@/src/components/pageComponents/home/Welcome', () => ({
+ default: () => ,
+}))
+
+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: () => ,
@@ -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,