diff --git a/components/react/authSignerModal.test.tsx b/components/react/authSignerModal.test.tsx index 0eb17cb..9ab13f9 100644 --- a/components/react/authSignerModal.test.tsx +++ b/components/react/authSignerModal.test.tsx @@ -1,9 +1,9 @@ -import { SignModalInner } from './authSignerModal'; +import { PromptSignModalInner, LedgerSignModalInner } from './authSignerModal'; import { test, expect, afterEach, describe, mock, jest } from 'bun:test'; import React from 'react'; import matchers from '@testing-library/jest-dom/matchers'; -import { fireEvent, screen, cleanup, within, waitFor } from '@testing-library/react'; -import { renderWithChainProvider } from '@/tests/render'; +import { fireEvent, screen, cleanup, render } from '@testing-library/react'; +import { renderWithChainProvider, renderWithWeb3AuthProvider } from '@/tests/render'; mock.module('next/router', () => ({ useRouter: jest.fn().mockReturnValue({ @@ -14,20 +14,24 @@ mock.module('next/router', () => ({ expect.extend(matchers); -describe('SignModalInner', () => { +describe('PromptSignModalInner', () => { afterEach(() => { cleanup(); }); test('should render', () => { - const wrapper = renderWithChainProvider( {}} />); + const wrapper = renderWithChainProvider( + {}} /> + ); expect(screen.getByText('Approve')).toBeInTheDocument(); const dialog = screen.getByRole('dialog'); expect(dialog).toBeVisible(); }); test('should not be visible initially when visible prop is false', () => { - const wrapper = renderWithChainProvider( {}} />); + const wrapper = renderWithChainProvider( + {}} /> + ); const dialog = screen.queryAllByRole('dialog'); expect(dialog.length).toBe(0); }); @@ -36,7 +40,7 @@ describe('SignModalInner', () => { let [isOpen, approved, rejected] = [true, false, false]; const wrapper = renderWithChainProvider( - { isOpen = false; @@ -71,7 +75,7 @@ describe('SignModalInner', () => { let [isOpen, approved, rejected] = [true, false, false]; const wrapper = renderWithChainProvider( - { isOpen = false; @@ -92,16 +96,11 @@ describe('SignModalInner', () => { expect(rejected).toBe(false); }); - // This test is skipped because: - // 1. The dialog element's Escape key behavior is handled natively by the browser - // and can't be easily tested in JSDOM (this test is currently failing). - // 2. JSDOM (used by Jest) doesn't fully implement dialog behaviors - // 3. While we could mock HTMLDialogElement, it wouldn't accurately test the actual browser behavior - test.skip('should close on pressing escape', () => { + test('should close on pressing escape', () => { let [isOpen, approved, rejected] = [true, false, false]; const wrapper = renderWithChainProvider( - { isOpen = false; @@ -116,13 +115,38 @@ describe('SignModalInner', () => { ); expect(isOpen).toBe(true); - const btn = screen.getByText('✕'); - btn.focus(); - document.dispatchEvent( - new KeyboardEvent('keydown', { key: 'Escape', bubbles: true, cancelable: true }) - ); + fireEvent.keyDown(document, { key: 'Escape', code: 'Escape' }); expect(isOpen).toBe(false); expect(approved).toBe(false); expect(rejected).toBe(false); }); }); + +describe('LedgerSignModalInner', () => { + afterEach(() => { + cleanup(); + }); + + test('should render', () => { + const wrapper = render( {}} />); + expect(screen.getByText('Ledger HSM')).toBeInTheDocument(); + const dialog = screen.getByRole('dialog'); + expect(dialog).toBeVisible(); + }); + + test('should NOT close on pressing escape', () => { + let isOpen = true; + const wrapper = render( (isOpen = false)} />); + + const dialog = screen.getByRole('dialog'); + + expect(isOpen).toBe(true); + fireEvent.keyDown(document, { key: 'Escape', code: 'Escape' }); + + // onClose is called. + expect(isOpen).toBe(false); + + // It does not cease to be visible. + expect(dialog).toBeVisible(); + }); +}); diff --git a/components/react/authSignerModal.tsx b/components/react/authSignerModal.tsx index 5606ac4..0dfc513 100644 --- a/components/react/authSignerModal.tsx +++ b/components/react/authSignerModal.tsx @@ -7,7 +7,7 @@ import { getRealLogo } from '@/utils'; import { useTheme } from '@/contexts'; import env from '@/config/env'; import { ArrowRightIcon } from '../icons'; -import { objectSyntax } from '../messageSyntax'; +import { objectSyntax } from '@/components'; import { MsgSend } from '@liftedinit/manifestjs/dist/codegen/cosmos/bank/v1beta1/tx'; import { MsgCreateGroupWithPolicy, @@ -30,9 +30,7 @@ import { MsgSetDenomMetadata, MsgCreateDenom, } from '@liftedinit/manifestjs/dist/codegen/osmosis/tokenfactory/v1beta1/tx'; -import { Dialog, Portal } from '@headlessui/react'; -import { wallets } from 'cosmos-kit'; -import { wallets as cosmosExtensionWallets } from '@cosmos-kit/cosmos-extension-metamask/cjs/cosmos-metamask-extension'; +import { Dialog } from '@headlessui/react'; import { Web3AuthContext } from '@/contexts/web3AuthContext'; type DisplayDataToSignProps = { @@ -104,12 +102,10 @@ const DisplayDataToSign = ({ data, address, className, - addressClassName, txInfoClassName, theme, }: DisplayDataToSignProps & { className?: string; - addressClassName?: string; txInfoClassName?: string; theme?: string; }) => { @@ -249,7 +245,8 @@ const DisplayDataToSign = ({ * @constructor */ export const SignModal = ({ id }: { id?: string }) => { - const { prompt, promptId } = useContext(Web3AuthContext); + const { wallet } = useWallet(); + const { prompt, promptId, isSigning } = useContext(Web3AuthContext); const [visible, setVisible] = useState(false); const [data, setData] = useState(undefined); @@ -262,36 +259,81 @@ export const SignModal = ({ id }: { id?: string }) => { } }, [promptId, id, prompt]); - if (!prompt || !data || !visible) { + if (!isSigning || !wallet) { return null; } - const approve = () => prompt.resolve(true); - const reject = () => prompt.resolve(false); + const showLedgerMessage = wallet.mode === 'ledger'; + + const approve = () => prompt?.resolve(true); + const reject = () => prompt?.resolve(false); + + if (showLedgerMessage) { + return {}} />; + } else { + return ( + + ); + } +}; + +export interface LedgerSignModalInnerProps { + onClose: () => void; +} +/** + * A signing modal that is displayed when a sign request is received, when the user + * is using a Ledger wallet. This should not be cancellable (as the user is expected to + * approve/reject on his Ledger device). + * @constructor + */ +export const LedgerSignModalInner: React.FC = ({ onClose }) => { return ( - + + ); }; -export const SignModalInner = ({ - visible, - data, - onClose, - reject, - approve, -}: { +export interface SignModalInnerProps { visible: boolean; data?: SignData; onClose: () => void; reject?: () => void; approve?: () => void; +} + +/** + * The actual signing modal (with transaction info) that is displayed when a sign request is + * received. + * @param visible Whether the modal is visible. + * @param data The transaction data to sign. This will be displayed to the user. + * @param onClose Callback when the modal is closed, whether it was approved or rejected. + * @param reject Callback when the user rejects the transaction. + * @param approve Callback when the user approves the transaction. + * @constructor + */ +export const PromptSignModalInner: React.FC = ({ + visible, + data, + onClose, + reject, + approve, }) => { const { wallet } = useWallet(); const { address } = useChain(env.chain); @@ -300,11 +342,11 @@ export const SignModalInner = ({ const walletIconString = walletIcon?.toString() ?? ''; function handleReject() { - reject && reject(); + reject?.(); onClose(); } function handleApprove() { - approve && approve(); + approve?.(); onClose(); } @@ -333,7 +375,6 @@ export const SignModalInner = ({ address={address ?? ''} theme={theme} className="space-y-4" - addressClassName="p-3 rounded-md text-sm overflow-auto h-12 dark:bg-[#E0E0FF0A] bg-[#E0E0FF0A] dark:border-[#FFFFFF33] border-[#00000033] border" txInfoClassName="p-3 rounded-md text-sm overflow-auto h-[32rem] dark:bg-[#E0E0FF0A] bg-[#E0E0FF0A] dark:border-[#FFFFFF33] border-[#00000033] border" /> diff --git a/components/react/index.ts b/components/react/index.ts index 7017d54..9410a12 100644 --- a/components/react/index.ts +++ b/components/react/index.ts @@ -3,4 +3,4 @@ export * from './modal'; export * from './views'; export * from './settingsModal'; export * from './inputs'; -export * from './authSignerModal'; +export { SignModal } from './authSignerModal'; diff --git a/contexts/web3AuthContext.tsx b/contexts/web3AuthContext.tsx index e93fa36..7064fd0 100644 --- a/contexts/web3AuthContext.tsx +++ b/contexts/web3AuthContext.tsx @@ -11,7 +11,7 @@ export interface Web3AuthContextType { signData: SignData; resolve: (approved: boolean) => void; }; - promptId: string | undefined; + promptId?: string; setPromptId: (id: string | undefined) => void; wallets: (MainWalletBase | Web3AuthWallet)[]; isSigning: boolean; diff --git a/tests/render.tsx b/tests/render.tsx index 25b322a..f32ece3 100644 --- a/tests/render.tsx +++ b/tests/render.tsx @@ -11,6 +11,7 @@ import { assets as axelarAssets, chain as axelarChain } from 'chain-registry/tes import { QueryClient, QueryClientProvider } from '@tanstack/react-query'; import { SkipProvider } from '@/contexts/skipGoContext'; import { manifestAssets, manifestChain } from '@/config/manifestChain'; +import { Web3AuthContext, Web3AuthContextType } from '@/contexts/web3AuthContext'; const defaultOptions = { chains: [manifestChain, osmosisChain, axelarChain], @@ -32,3 +33,14 @@ export const renderWithChainProvider = (ui: React.ReactElement, options = {}) => options ); }; + +export const renderWithWeb3AuthProvider = ( + ui: React.ReactNode, + context: Web3AuthContextType, + options = {} +) => { + return renderWithChainProvider( + {ui}, + options + ); +};