import React, { act } from 'react' import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest' import { createRoot } from 'react-dom/client' import { ConnectionContext } from '../providers/ConnectionProvider' import { useSignSolanaTransaction } from './useSignSolanaTransaction' import type { UseSignSolanaTransactionReturn } from '@meshconnect/uwc-types' // --------------------------------------------------------------------------- // Helpers // --------------------------------------------------------------------------- /** Minimal stub that satisfies ConnectionContextValue's connector shape. */ function makeConnector( signSolanaTransaction: (tx: Uint8Array) => Promise ) { return { signSolanaTransaction, // other connector methods are not exercised by this hook getSession: () => ({ isConnected: false }), isReady: () => true, subscribe: () => () => {}, getWallets: () => [], getNetworks: () => [] } } /** * Minimal context value that wraps a connector stub. Pass `{ activeAddress }` * as an object (not a bare arg) so an explicit `undefined` survives — a default * parameter would otherwise replace `undefined` with the connected address. */ function makeContextValue( connector: ReturnType, { activeAddress }: { activeAddress: string | undefined } = { activeAddress: 'SoLanaTestAddr1111111111111111111111111111' } ) { return { connector, session: { isConnected: activeAddress !== undefined, walletId: undefined, networkId: undefined, activeAddress }, wallets: [], networks: [], isReady: true } } /** * Renders `useSignSolanaTransaction` inside a component that exposes its * return value via a captured ref. Returns the ref and cleanup helpers. * * Pattern mirrors ConnectionProvider.singleton.test.tsx: createRoot + act, * no @testing-library/react (not installed in this package). */ function renderHookInContext( contextValue: ReturnType ) { const captured: { current: UseSignSolanaTransactionReturn | null } = { current: null } function Capture() { captured.current = useSignSolanaTransaction() return null } const container = document.createElement('div') document.body.appendChild(container) const root = createRoot(container) async function render() { await act(async () => { root.render( ) }) } async function unmount() { await act(async () => { root.unmount() }) document.body.removeChild(container) } return { captured, render, unmount } } // --------------------------------------------------------------------------- // Tests // --------------------------------------------------------------------------- describe('useSignSolanaTransaction', () => { let container: HTMLDivElement let root: ReturnType beforeEach(() => { container = document.createElement('div') document.body.appendChild(container) root = createRoot(container) }) afterEach(async () => { await act(async () => { root.unmount() }) if (document.body.contains(container)) { document.body.removeChild(container) } }) // (a) throws when used outside ConnectionProvider it('throws when rendered outside ConnectionProvider', () => { function BareHook() { useSignSolanaTransaction() return null } expect(() => { // Synchronous render — React will throw during render, caught here. // Suppress the React error-boundary noise this logs on the way out. const errorSpy = vi.spyOn(console, 'error').mockImplementation(() => {}) try { act(() => { root.render() }) } finally { errorSpy.mockRestore() } }).toThrowError( 'useSignSolanaTransaction must be used within a ConnectionProvider' ) }) // (b) returns the Uint8Array from connector.signSolanaTransaction on success it('returns the signed bytes from connector on success', async () => { const signedBytes = new Uint8Array([1, 2, 3, 4]) const mockSign = vi.fn().mockResolvedValue(signedBytes) const ctxValue = makeContextValue(makeConnector(mockSign)) const { captured, render, unmount } = renderHookInContext(ctxValue) await render() expect(captured.current).not.toBeNull() const input = new Uint8Array([9, 8, 7]) let result: Uint8Array | undefined await act(async () => { result = await captured.current!.signSolanaTransaction(input) }) expect(mockSign).toHaveBeenCalledOnce() expect(mockSign).toHaveBeenCalledWith(input) expect(result).toBe(signedBytes) await unmount() }) // (c) sets error state and re-throws when connector.signSolanaTransaction rejects it('sets error state and re-throws when connector rejects', async () => { const walletError = { code: 4001, message: 'User rejected' } const mockSign = vi.fn().mockRejectedValue(walletError) const ctxValue = makeContextValue(makeConnector(mockSign)) const { captured, render, unmount } = renderHookInContext(ctxValue) await render() let thrownError: unknown await act(async () => { try { await captured.current!.signSolanaTransaction(new Uint8Array([1])) } catch (err) { thrownError = err } }) expect(thrownError).toBe(walletError) expect(captured.current!.error).toBe(walletError) await unmount() }) // (d) isLoading is true during the call and false after — success path it('sets isLoading=true during call and false after success', async () => { let resolveSign!: (v: Uint8Array) => void const pendingSign = new Promise(res => { resolveSign = res }) const mockSign = vi.fn().mockReturnValue(pendingSign) const ctxValue = makeContextValue(makeConnector(mockSign)) const { captured, render, unmount } = renderHookInContext(ctxValue) await render() // Start the async call but don't await it yet — capture loading mid-flight let callPromise: Promise await act(async () => { callPromise = captured.current!.signSolanaTransaction(new Uint8Array([5])) }) // At this point the hook has flipped isLoading=true (setIsLoading runs before the await) expect(captured.current!.isLoading).toBe(true) // Settle the promise await act(async () => { resolveSign(new Uint8Array([99])) await callPromise! }) expect(captured.current!.isLoading).toBe(false) await unmount() }) // (d) isLoading is false after failure path it('sets isLoading=false after connector rejects', async () => { let rejectSign!: (err: unknown) => void const pendingSign = new Promise((_, rej) => { rejectSign = rej }) const mockSign = vi.fn().mockReturnValue(pendingSign) const ctxValue = makeContextValue(makeConnector(mockSign)) const { captured, render, unmount } = renderHookInContext(ctxValue) await render() let callPromise: Promise await act(async () => { callPromise = captured.current!.signSolanaTransaction(new Uint8Array([6])) }) expect(captured.current!.isLoading).toBe(true) await act(async () => { rejectSign({ code: 4001, message: 'cancelled' }) try { await callPromise! } catch { // expected } }) expect(captured.current!.isLoading).toBe(false) await unmount() }) // (e) throws "No wallet connected" before touching the connector when no active address it('throws "No wallet connected" when there is no active address', async () => { const mockSign = vi.fn() const ctxValue = makeContextValue(makeConnector(mockSign), { activeAddress: undefined }) const { captured, render, unmount } = renderHookInContext(ctxValue) await render() let thrownError: unknown await act(async () => { try { await captured.current!.signSolanaTransaction(new Uint8Array([1])) } catch (err) { thrownError = err } }) expect((thrownError as Error).message).toBe('No wallet connected') expect(mockSign).not.toHaveBeenCalled() await unmount() }) // (f) rejects empty input before touching the connector it('throws "Transaction bytes are required" for empty bytes', async () => { const mockSign = vi.fn() const ctxValue = makeContextValue(makeConnector(mockSign)) const { captured, render, unmount } = renderHookInContext(ctxValue) await render() let thrownError: unknown await act(async () => { try { await captured.current!.signSolanaTransaction(new Uint8Array(0)) } catch (err) { thrownError = err } }) expect((thrownError as Error).message).toBe( 'Transaction bytes are required' ) expect(mockSign).not.toHaveBeenCalled() await unmount() }) })