From 38a047140043a2094135029b04dfffac9639eec5 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Estev=C3=A3o?= Date: Mon, 1 Dec 2025 15:26:24 -0300 Subject: [PATCH 01/26] chore: single button page to simplified tests --- paybutton/dev/demo/single.html | 40 ++++++++++++++++++++++++++++++++++ 1 file changed, 40 insertions(+) create mode 100644 paybutton/dev/demo/single.html diff --git a/paybutton/dev/demo/single.html b/paybutton/dev/demo/single.html new file mode 100644 index 00000000..43c71c1b --- /dev/null +++ b/paybutton/dev/demo/single.html @@ -0,0 +1,40 @@ + + + + + + Paybutton Bundle Test + + + + + + + +
+ + From 28ed757069404c22c6dbe38747a89c5813f7de3c Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Estev=C3=A3o?= Date: Mon, 1 Dec 2025 16:26:57 -0300 Subject: [PATCH 02/26] fix: paymentId on open --- react/lib/components/PayButton/PayButton.tsx | 91 +++++++++----------- 1 file changed, 42 insertions(+), 49 deletions(-) diff --git a/react/lib/components/PayButton/PayButton.tsx b/react/lib/components/PayButton/PayButton.tsx index 860e7f0a..87838814 100644 --- a/react/lib/components/PayButton/PayButton.tsx +++ b/react/lib/components/PayButton/PayButton.tsx @@ -132,15 +132,6 @@ export const PayButton = ({ cryptoAmountRef.current = cryptoAmount; }, [cryptoAmount]); - const waitPrice = (callback: Function) => { - const intervalId = setInterval(() => { - if (priceRef.current !== 0) { - clearInterval(intervalId); - callback(); - } - }, 300); - }; - const getPaymentId = useCallback(async ( currency: Currency, @@ -169,58 +160,38 @@ export const PayButton = ({ [disablePaymentId, apiBaseUrl, randomSatoshis, convertedCurrencyObj] ) - const lastPaymentAmount = useRef(undefined) - useEffect(() => { + const lastPaymentAmount = useRef(undefined); - if ( - !dialogOpen || - disablePaymentId || - !to - ) { - return; - } + const handleButtonClick = useCallback(async (): Promise => { + let finalPaymentId = paymentId; - let effectiveAmount: number | null - if (isFiat(currency)) { - if (!convertedCurrencyObj) { - // Conversion not ready yet – wait for convertedCurrencyObj update - return; - } - effectiveAmount = convertedCurrencyObj.float; - } else if (amount === undefined) { - effectiveAmount = null - } else { - const amountNumber = Number(amount); - if (Number.isNaN(amountNumber)) { - return; - } - effectiveAmount = amountNumber; - } - if (lastPaymentAmount.current === effectiveAmount) { - return; - } + if (!disablePaymentId && to && (!finalPaymentId || lastPaymentAmount.current === undefined)) { + let effectiveAmount: number | undefined; - lastPaymentAmount.current = effectiveAmount; + if (isFiat(currency)) { + effectiveAmount = convertedCurrencyObj?.float; + } else if (amount !== undefined) { + const parsed = Number(amount); + if (!Number.isNaN(parsed)) { + effectiveAmount = parsed; + } + } - void getPaymentId( - currency, - to, - effectiveAmount ?? undefined, - ); - }, [amount, currency, to, dialogOpen, disablePaymentId, paymentId, getPaymentId, convertedCurrencyObj]); + finalPaymentId = await getPaymentId(currency, to, effectiveAmount); + lastPaymentAmount.current = effectiveAmount; + } - const handleButtonClick = useCallback(async (): Promise => { if (onOpen) { if (isFiat(currency)) { - void waitPrice(() => onOpen(cryptoAmountRef.current, to, paymentId)) + onOpen(cryptoAmountRef.current, to, finalPaymentId); } else { - onOpen(amount, to, paymentId) + onOpen(amount, to, finalPaymentId); } } - setDialogOpen(true) + setDialogOpen(true); }, [ onOpen, currency, @@ -229,7 +200,29 @@ export const PayButton = ({ paymentId, disablePaymentId, getPaymentId, - ]) + convertedCurrencyObj, + ]); + + useEffect(() => { + if (!dialogOpen || disablePaymentId || !to) return; + + let effectiveAmount: number | undefined; + + if (isFiat(currency)) { + effectiveAmount = convertedCurrencyObj?.float; + } else if (amount !== undefined) { + const parsed = Number(amount); + if (!Number.isNaN(parsed)) { + effectiveAmount = parsed; + } + } + + if (effectiveAmount !== undefined && lastPaymentAmount.current !== effectiveAmount) { + lastPaymentAmount.current = effectiveAmount; + void getPaymentId(currency, to, effectiveAmount); + } + }, [dialogOpen, amount, convertedCurrencyObj, currency, getPaymentId, disablePaymentId, to]); + const handleCloseDialog = (success?: boolean, paymentId?: string): void => { if (onClose !== undefined) onClose(success, paymentId); From 13a1776fc4a75c0440c50052edabff7378ba0956 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Estev=C3=A3o?= Date: Mon, 1 Dec 2025 16:27:04 -0300 Subject: [PATCH 03/26] fix: regenerating for undefined --- react/lib/components/PayButton/PayButton.tsx | 8 +++++--- 1 file changed, 5 insertions(+), 3 deletions(-) diff --git a/react/lib/components/PayButton/PayButton.tsx b/react/lib/components/PayButton/PayButton.tsx index 87838814..a6f968e9 100644 --- a/react/lib/components/PayButton/PayButton.tsx +++ b/react/lib/components/PayButton/PayButton.tsx @@ -160,13 +160,13 @@ export const PayButton = ({ [disablePaymentId, apiBaseUrl, randomSatoshis, convertedCurrencyObj] ) - const lastPaymentAmount = useRef(undefined); + const lastPaymentAmount = useRef(undefined); const handleButtonClick = useCallback(async (): Promise => { let finalPaymentId = paymentId; if (!disablePaymentId && to && (!finalPaymentId || lastPaymentAmount.current === undefined)) { - let effectiveAmount: number | undefined; + let effectiveAmount: number | null | undefined; if (isFiat(currency)) { effectiveAmount = convertedCurrencyObj?.float; @@ -175,9 +175,11 @@ export const PayButton = ({ if (!Number.isNaN(parsed)) { effectiveAmount = parsed; } + } else { + effectiveAmount = null } - finalPaymentId = await getPaymentId(currency, to, effectiveAmount); + finalPaymentId = await getPaymentId(currency, to, effectiveAmount ?? undefined); lastPaymentAmount.current = effectiveAmount; } From 8c052f28c30e353edee24cd542a4743190d13248 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Estev=C3=A3o?= Date: Mon, 1 Dec 2025 16:47:45 -0300 Subject: [PATCH 04/26] test: add test for 13a1776f --- react/lib/tests/components/PayButton.test.tsx | 33 +++++++++++++++++++ 1 file changed, 33 insertions(+) diff --git a/react/lib/tests/components/PayButton.test.tsx b/react/lib/tests/components/PayButton.test.tsx index 5db7dcf7..df021eec 100644 --- a/react/lib/tests/components/PayButton.test.tsx +++ b/react/lib/tests/components/PayButton.test.tsx @@ -289,5 +289,38 @@ describe('PayButton', () => { expect(createPayment).toHaveBeenCalledTimes(1) }) }) + + + it('does not regenerate paymentId when amount is undefined across reopen', async () => { + const user = userEvent.setup() + const onOpen = jest.fn() + + // createPayment calls should start at 0 for this test + expect(createPayment).toHaveBeenCalledTimes(0) + + render( + + ) + + // Still 0 right after render, before opening + expect(createPayment).toHaveBeenCalledTimes(0) + + // First open + await user.click(screen.getByRole('button', { name: /donate/i })) + await waitFor(() => { + expect(createPayment).toHaveBeenCalledTimes(1) + }) + + // Second open (we don't actually need a close button for the paymentId behavior) + await user.click(screen.getByRole('button', { name: /donate/i })) + await waitFor(() => { + expect(createPayment).toHaveBeenCalledTimes(1) + }) + }) }) + From a1c1612933c4b849289646496ced97d7ec4de0ae Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Estev=C3=A3o?= Date: Mon, 1 Dec 2025 16:49:09 -0300 Subject: [PATCH 05/26] test: test for not regenerating paymentId across reopenings --- react/lib/tests/components/PayButton.test.tsx | 37 +++++++++++++++++++ 1 file changed, 37 insertions(+) diff --git a/react/lib/tests/components/PayButton.test.tsx b/react/lib/tests/components/PayButton.test.tsx index df021eec..1af56995 100644 --- a/react/lib/tests/components/PayButton.test.tsx +++ b/react/lib/tests/components/PayButton.test.tsx @@ -321,6 +321,43 @@ describe('PayButton', () => { expect(createPayment).toHaveBeenCalledTimes(1) }) }) + it('does not regenerate paymentId when using fixed numeric amount across reopen', async () => { + const user = userEvent.setup() + const onOpen = jest.fn() + + // sanity check — should start at zero + expect(createPayment).toHaveBeenCalledTimes(0) + + render( + + ) + + // still 0 after mount + expect(createPayment).toHaveBeenCalledTimes(0) + + // first open + await user.click(screen.getByRole('button', { name: /donate/i })) + await waitFor(() => { + expect(createPayment).toHaveBeenCalledTimes(1) + }) + + // second open with same amount + await user.click(screen.getByRole('button', { name: /donate/i })) + await waitFor(() => { + expect(createPayment).toHaveBeenCalledTimes(1) + }) + + // third open: still same amount + await user.click(screen.getByRole('button', { name: /donate/i })) + await waitFor(() => { + expect(createPayment).toHaveBeenCalledTimes(1) + }) + }) }) From 866a62acb23ba3642f2549786cbd55ed7c7ff95d Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Estev=C3=A3o?= Date: Mon, 1 Dec 2025 17:14:02 -0300 Subject: [PATCH 06/26] fix:
inside

--- react/lib/components/Widget/Widget.tsx | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/react/lib/components/Widget/Widget.tsx b/react/lib/components/Widget/Widget.tsx index ea71a9d5..c162e579 100644 --- a/react/lib/components/Widget/Widget.tsx +++ b/react/lib/components/Widget/Widget.tsx @@ -1308,7 +1308,7 @@ export const Widget: React.FunctionComponent = props => { ) : null} - + Powered by PayButton.org {(() => { From 77ec5ab4526e74f7955bd384d68f9f0ec03be046 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Estev=C3=A3o?= Date: Mon, 1 Dec 2025 17:14:21 -0300 Subject: [PATCH 07/26] test: add tests for paybutton --- react/lib/tests/components/PayButton.test.tsx | 302 ++++++++++++++++-- 1 file changed, 275 insertions(+), 27 deletions(-) diff --git a/react/lib/tests/components/PayButton.test.tsx b/react/lib/tests/components/PayButton.test.tsx index 1af56995..92300b24 100644 --- a/react/lib/tests/components/PayButton.test.tsx +++ b/react/lib/tests/components/PayButton.test.tsx @@ -1,9 +1,23 @@ // FILE: react/lib/tests/components/PayButton.test.tsx -import { render, screen, waitFor } from '@testing-library/react' +import { act, render, screen, waitFor } from '@testing-library/react' import userEvent from '@testing-library/user-event' import { PayButton } from '../../components/PayButton' import { createPayment } from '../../util/api-client' +const realConsoleError = console.error +beforeAll(() => { + console.error = (...args: any[]) => { + if (args.some(a => typeof a === 'string' && a.includes('Error creating payment ID'))) { + return + } + realConsoleError(...args) + } +}) + +afterAll(() => { + console.error = realConsoleError +}) + jest.mock('../../util', () => ({ ...jest.requireActual('../../util'), getFiatPrice: jest.fn(async () => 100), @@ -11,33 +25,38 @@ jest.mock('../../util', () => ({ setupAltpaymentSocket: jest.fn(() => Promise.resolve(undefined)), })) +let externalSetConvertedCurrencyObj: any = null; + jest.mock('../../components/PaymentDialog', () => ({ - PaymentDialog: (props: any) => ( -

- - -
- ), + PaymentDialog: (props: any) => { + externalSetConvertedCurrencyObj = props.setConvertedCurrencyObj; + return ( +
+ + +
+ ) + }, })) jest.mock('../../util/api-client', () => ({ @@ -358,6 +377,235 @@ describe('PayButton', () => { expect(createPayment).toHaveBeenCalledTimes(1) }) }) + it('regenerates paymentId only when the numeric amount actually changes while dialog is open (crypto)', async () => { + const user = userEvent.setup() + const onOpen = jest.fn() + + expect(createPayment).toHaveBeenCalledTimes(0) + + render( + + ) + + // First open → generate paymentId(2) + await user.click(screen.getByRole('button', { name: /donate/i })) + await waitFor(() => { + expect(createPayment).toHaveBeenCalledTimes(1) + }) + + // Change to 3 → regenerate paymentId + await user.click(screen.getByRole('button', { name: 'mock-change-amount' })) + await waitFor(() => { + expect(createPayment).toHaveBeenCalledTimes(2) + }) + + // Change to 4 → regenerate paymentId again + await user.click(screen.getByRole('button', { name: 'mock-change-amount' })) + await waitFor(() => { + expect(createPayment).toHaveBeenCalledTimes(3) + }) + + // Reopen multiple times at amount=4 → must not regenerate + await user.click(screen.getByRole('button', { name: /donate/i })) + await user.click(screen.getByRole('button', { name: /donate/i })) + await user.click(screen.getByRole('button', { name: /donate/i })) + + await waitFor(() => { + expect(createPayment).toHaveBeenCalledTimes(3) + }) + }) + it('uses convertedCurrencyObj.float as the effective amount for fiat, not the raw amount', async () => { + const user = userEvent.setup() + const onOpen = jest.fn() + + expect(createPayment).toHaveBeenCalledTimes(0) + + render( + + ) + + // simulate conversion (first computed by dialog) + await user.click( + screen.getByRole('button', { name: 'mock-set-converted' }) + ) + + // open dialog + await user.click(screen.getByRole('button', { name: /donate/i })) + + await waitFor(() => { + expect(createPayment).toHaveBeenCalledTimes(1) + }) + + // Check that raw amount=100 was NOT used + const [firstAmountUsed] = (createPayment as jest.Mock).mock.calls[0] + + expect(firstAmountUsed).not.toBe(100) + expect(firstAmountUsed).toBeCloseTo(0.12345678, 8) + }) + it('does not regenerate paymentId for fiat when only raw amount changes and converted stays same', async () => { + const user = userEvent.setup() + const onOpen = jest.fn() + + expect(createPayment).toHaveBeenCalledTimes(0) + + render( + + ) + + // Initial conversion + await user.click(screen.getByRole('button', { name: 'mock-set-converted' })) + + // First open (convertedAmount=0.12345678) + await user.click(screen.getByRole('button', { name: /donate/i })) + await waitFor(() => { + expect(createPayment).toHaveBeenCalledTimes(1) + }) + + // Change raw amount 50 → 51 + await user.click(screen.getByRole('button', { name: 'mock-change-amount' })) + + // no new createPayment + await waitFor(() => { + expect(createPayment).toHaveBeenCalledTimes(1) + }) + }) + it('regenerates paymentId for fiat only when convertedCurrencyObj.float changes', async () => { + const user = userEvent.setup() + + expect(createPayment).toHaveBeenCalledTimes(0) + + render( + + ) + + // First conversion + await user.click(screen.getByRole('button', { name: 'mock-set-converted' })) + + // First open → ID created at 0.12345678 + await user.click(screen.getByRole('button', { name: /donate/i })) + await waitFor(() => { + expect(createPayment).toHaveBeenCalledTimes(1) + }) + + // reset counter + ;(createPayment as jest.Mock).mockClear() + + // Now simulate a different conversion manually + await act(async () => { + externalSetConvertedCurrencyObj({ + float: 0.98765432, + string: '0.98765432', + currency: 'XEC', + }) + }) + + // Now must regenerate + await waitFor(() => { + expect(createPayment).toHaveBeenCalledTimes(1) + }) + }) + it('ensures onOpen always receives a defined paymentId and reuses it across reopen', async () => { + const user = userEvent.setup() + const onOpen = jest.fn() + + render( + + ) + + // First open + await user.click(screen.getByRole('button', { name: /donate/i })) + await waitFor(() => { + expect(onOpen).toHaveBeenCalledTimes(1) + }) + + const firstArgs = (onOpen as jest.Mock).mock.calls[0] + expect(firstArgs[2]).toBe('mock-payment-id') + + // Reopen multiple times + await user.click(screen.getByRole('button', { name: /donate/i })) + await user.click(screen.getByRole('button', { name: /donate/i })) + + await waitFor(() => { + expect(onOpen).toHaveBeenCalledTimes(3) + }) + + const secondArgs = (onOpen as jest.Mock).mock.calls[1] + const thirdArgs = (onOpen as jest.Mock).mock.calls[2] + + expect(secondArgs[2]).toBe('mock-payment-id') + expect(thirdArgs[2]).toBe('mock-payment-id') + }) + it('never generates paymentId when disablePaymentId=true', async () => { + const user = userEvent.setup() + + render( + + ) + + await user.click(screen.getByRole('button', { name: /donate/i })) + await user.click(screen.getByRole('button', { name: /donate/i })) + await user.click(screen.getByRole('button', { name: /donate/i })) + + await waitFor(() => { + expect(createPayment).not.toHaveBeenCalled() + }) + }) + it('handles getPaymentId failure without crashing and does not call onOpen with undefined id', async () => { + const user = userEvent.setup() + const onOpen = jest.fn() + + ;(createPayment as jest.Mock).mockImplementationOnce(async () => { + throw new Error('server offline') + }) + + render( + + ) + + await user.click(screen.getByRole('button', { name: /donate/i })) + + await waitFor(() => { + expect(onOpen).toHaveBeenCalledTimes(1) + }) + + const args = (onOpen as jest.Mock).mock.calls[0] + expect(args[2]).toBeUndefined() + }) + }) From 3abdbdb0267ab00501fd50a9d95a2d598b2bbcea Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Estev=C3=A3o?= Date: Mon, 1 Dec 2025 17:24:40 -0300 Subject: [PATCH 08/26] fix: open ui immediatly (WIP) --- react/lib/components/PayButton/PayButton.tsx | 75 ++++++++------------ 1 file changed, 28 insertions(+), 47 deletions(-) diff --git a/react/lib/components/PayButton/PayButton.tsx b/react/lib/components/PayButton/PayButton.tsx index a6f968e9..02fef29d 100644 --- a/react/lib/components/PayButton/PayButton.tsx +++ b/react/lib/components/PayButton/PayButton.tsx @@ -163,37 +163,39 @@ export const PayButton = ({ const lastPaymentAmount = useRef(undefined); const handleButtonClick = useCallback(async (): Promise => { - let finalPaymentId = paymentId; - - if (!disablePaymentId && to && (!finalPaymentId || lastPaymentAmount.current === undefined)) { - let effectiveAmount: number | null | undefined; + // OPEN UI IMMEDIATELY + setDialogOpen(true); - if (isFiat(currency)) { - effectiveAmount = convertedCurrencyObj?.float; - } else if (amount !== undefined) { - const parsed = Number(amount); - if (!Number.isNaN(parsed)) { - effectiveAmount = parsed; + // Fetch paymentId async — but don’t block UI + queueMicrotask(async () => { + let finalPaymentId = paymentId; + + if (!disablePaymentId && to && (!finalPaymentId || lastPaymentAmount.current === undefined)) { + let effectiveAmount: number | null | undefined; + + if (isFiat(currency)) { + effectiveAmount = convertedCurrencyObj?.float; + } else if (amount !== undefined) { + const parsed = Number(amount); + if (!Number.isNaN(parsed)) { + effectiveAmount = parsed; + } + } else { + effectiveAmount = null } - } else { - effectiveAmount = null - } - - finalPaymentId = await getPaymentId(currency, to, effectiveAmount ?? undefined); - - lastPaymentAmount.current = effectiveAmount; - } - - if (onOpen) { - if (isFiat(currency)) { - onOpen(cryptoAmountRef.current, to, finalPaymentId); - } else { - onOpen(amount, to, finalPaymentId); + finalPaymentId = await getPaymentId(currency, to, effectiveAmount ?? undefined); + lastPaymentAmount.current = effectiveAmount; } - } - setDialogOpen(true); + if (onOpen) { + if (isFiat(currency)) { + onOpen(cryptoAmountRef.current, to, finalPaymentId); + } else { + onOpen(amount, to, finalPaymentId); + } + } + }); }, [ onOpen, currency, @@ -205,27 +207,6 @@ export const PayButton = ({ convertedCurrencyObj, ]); - useEffect(() => { - if (!dialogOpen || disablePaymentId || !to) return; - - let effectiveAmount: number | undefined; - - if (isFiat(currency)) { - effectiveAmount = convertedCurrencyObj?.float; - } else if (amount !== undefined) { - const parsed = Number(amount); - if (!Number.isNaN(parsed)) { - effectiveAmount = parsed; - } - } - - if (effectiveAmount !== undefined && lastPaymentAmount.current !== effectiveAmount) { - lastPaymentAmount.current = effectiveAmount; - void getPaymentId(currency, to, effectiveAmount); - } - }, [dialogOpen, amount, convertedCurrencyObj, currency, getPaymentId, disablePaymentId, to]); - - const handleCloseDialog = (success?: boolean, paymentId?: string): void => { if (onClose !== undefined) onClose(success, paymentId); setDialogOpen(false); From 030a899622a9be3b81bd3a7db0bbd5f82ae50a06 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Estev=C3=A3o?= Date: Wed, 3 Dec 2025 13:13:22 -0300 Subject: [PATCH 09/26] fix: onOpen with correct values, leaving amount updated --- react/lib/components/PayButton/PayButton.tsx | 118 +++++++++++++------ react/lib/components/Widget/Widget.tsx | 14 ++- 2 files changed, 90 insertions(+), 42 deletions(-) diff --git a/react/lib/components/PayButton/PayButton.tsx b/react/lib/components/PayButton/PayButton.tsx index 02fef29d..dab49ca8 100644 --- a/react/lib/components/PayButton/PayButton.tsx +++ b/react/lib/components/PayButton/PayButton.tsx @@ -161,52 +161,94 @@ export const PayButton = ({ ) const lastPaymentAmount = useRef(undefined); + const lastOnOpenPaymentId = useRef(undefined); + const hasFiredOnOpenRef = useRef(false); - const handleButtonClick = useCallback(async (): Promise => { - // OPEN UI IMMEDIATELY - setDialogOpen(true); + useEffect(() => { + if (!dialogOpen || disablePaymentId || !to) return; - // Fetch paymentId async — but don’t block UI - queueMicrotask(async () => { - let finalPaymentId = paymentId; - - if (!disablePaymentId && to && (!finalPaymentId || lastPaymentAmount.current === undefined)) { - let effectiveAmount: number | null | undefined; - - if (isFiat(currency)) { - effectiveAmount = convertedCurrencyObj?.float; - } else if (amount !== undefined) { - const parsed = Number(amount); - if (!Number.isNaN(parsed)) { - effectiveAmount = parsed; - } - } else { - effectiveAmount = null - } + let effectiveAmount: number | null | undefined; - finalPaymentId = await getPaymentId(currency, to, effectiveAmount ?? undefined); - lastPaymentAmount.current = effectiveAmount; - } + if (isFiat(currency)) { + effectiveAmount = convertedCurrencyObj?.float ?? null; + } else if (amount !== undefined) { + const parsed = Number(amount); + effectiveAmount = Number.isNaN(parsed) ? null : parsed; + } else { + effectiveAmount = null; + } - if (onOpen) { - if (isFiat(currency)) { - onOpen(cryptoAmountRef.current, to, finalPaymentId); - } else { - onOpen(amount, to, finalPaymentId); - } + if (effectiveAmount === lastPaymentAmount.current) { + return; + } + + lastPaymentAmount.current = effectiveAmount; + + // Clear current paymentId so child widget goes into "waiting" state + setPaymentId(undefined); + + void getPaymentId(currency, to, effectiveAmount ?? undefined); + }, [ + dialogOpen, + currency, + amount, + convertedCurrencyObj, + disablePaymentId, + to, + getPaymentId, + ]); + + useEffect(() => { + if (!dialogOpen) { + hasFiredOnOpenRef.current = false; + lastOnOpenPaymentId.current = undefined; + return; + } + + if (!onOpen || hasFiredOnOpenRef.current) { + return; + } + + if (disablePaymentId) { + hasFiredOnOpenRef.current = true; + lastOnOpenPaymentId.current = '__no_paymentid__'; + + if (isFiat(currency)) { + onOpen(cryptoAmountRef.current, to, undefined); + } else { + onOpen(amount, to, undefined); } - }); + return; + } + + if (!paymentId) { + return; + } + + hasFiredOnOpenRef.current = true; + lastOnOpenPaymentId.current = paymentId; + + if (isFiat(currency)) { + onOpen(cryptoAmountRef.current, to, paymentId); + } else { + onOpen(amount, to, paymentId); + } }, [ + dialogOpen, onOpen, + paymentId, currency, amount, to, - paymentId, disablePaymentId, - getPaymentId, - convertedCurrencyObj, ]); + + const handleButtonClick = useCallback((): void => { + setDialogOpen(true); + }, []); + + const handleCloseDialog = (success?: boolean, paymentId?: string): void => { if (onClose !== undefined) onClose(success, paymentId); setDialogOpen(false); @@ -296,18 +338,16 @@ export const PayButton = ({ }, [dialogOpen, useAltpayment]); useEffect(() => { - if (dialogOpen === false && initialAmount && currency) { + if (initialAmount != null && currency) { const obj = getCurrencyObject( Number(initialAmount), currency, randomSatoshis, ); - setTimeout(() => { - setAmount(obj.float); - setCurrencyObj(obj); - }, 300); + setAmount(obj.float); + setCurrencyObj(obj); } - }, [dialogOpen, initialAmount, currency, randomSatoshis]); + }, [initialAmount, currency, randomSatoshis]) const getPrice = useCallback( async () => { diff --git a/react/lib/components/Widget/Widget.tsx b/react/lib/components/Widget/Widget.tsx index c162e579..47507e26 100644 --- a/react/lib/components/Widget/Widget.tsx +++ b/react/lib/components/Widget/Widget.tsx @@ -181,10 +181,14 @@ export const Widget: React.FunctionComponent = props => { const inputRef = React.useRef(null) const lastEffectiveAmountRef = React.useRef(undefined) + const [standalonePaymentPending, setStandalonePaymentPending] = useState(false) + const isWaitingForPaymentId = - isChild === true && - !disablePaymentId && - paymentId === undefined + !disablePaymentId && ( + (isChild === true && paymentId === undefined) || + (isChild !== true && standalonePaymentPending) + ) + const qrLoading = loading || isWaitingForPaymentId @@ -627,6 +631,8 @@ export const Widget: React.FunctionComponent = props => { } lastEffectiveAmountRef.current = effectiveAmount; + setStandalonePaymentPending(true) + const responsePaymentId = await createPayment( effectiveAmount ?? undefined, to, @@ -635,6 +641,8 @@ export const Widget: React.FunctionComponent = props => { setPaymentId(responsePaymentId); } catch (error) { console.error('Error creating payment ID:', error); + } finally { + setStandalonePaymentPending(false) } }; From 30f3bea5f79bddfd5458c6a686ef6d1f9b30c6eb Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Estev=C3=A3o?= Date: Wed, 3 Dec 2025 19:15:33 -0300 Subject: [PATCH 10/26] fix: optional props, wait for loading before being able to click button --- react/lib/components/Widget/Widget.tsx | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/react/lib/components/Widget/Widget.tsx b/react/lib/components/Widget/Widget.tsx index 47507e26..a9aa6039 100644 --- a/react/lib/components/Widget/Widget.tsx +++ b/react/lib/components/Widget/Widget.tsx @@ -64,11 +64,11 @@ export interface WidgetProps { disablePaymentId?: boolean text?: string ButtonComponent?: React.ComponentType - success: boolean + success?: boolean successText?: string theme?: ThemeName | Theme foot?: React.ReactNode - disabled: boolean + disabled?: boolean goalAmount?: number | string | null currency?: Currency animation?: animation @@ -1275,7 +1275,7 @@ export const Widget: React.FunctionComponent = props => { text: widgetButtonText, hoverText, onClick: handleButtonClick, - disabled: isPropsTrue(disabled), + disabled: isPropsTrue(disabled) || qrLoading, animation, size: 'medium', }) From 2143dd59b1c7e9412105f9ae429ddf11a7367925 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Estev=C3=A3o?= Date: Wed, 3 Dec 2025 19:16:44 -0300 Subject: [PATCH 11/26] fix: don't allow copying qrcode before paymentId loads --- react/lib/components/Widget/Widget.tsx | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/react/lib/components/Widget/Widget.tsx b/react/lib/components/Widget/Widget.tsx index a9aa6039..b89f3cb6 100644 --- a/react/lib/components/Widget/Widget.tsx +++ b/react/lib/components/Widget/Widget.tsx @@ -955,7 +955,7 @@ export const Widget: React.FunctionComponent = props => { } const handleQrCodeClick = useCallback((): void => { - if (disabled || to === undefined) return + if (disabled || to === undefined || qrLoading) return if (!url || !copyToClipboard(url)) return setCopied(true) setRecentlyCopied(true) From 5d7bc59b500a9ad17d4953db559be22d1fa87569 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Estev=C3=A3o?= Date: Wed, 3 Dec 2025 19:49:04 -0300 Subject: [PATCH 12/26] test: improve widget tests --- react/lib/tests/components/Widget.test.tsx | 637 +++++++++++++-------- 1 file changed, 388 insertions(+), 249 deletions(-) diff --git a/react/lib/tests/components/Widget.test.tsx b/react/lib/tests/components/Widget.test.tsx index 7c3da136..52f28eef 100644 --- a/react/lib/tests/components/Widget.test.tsx +++ b/react/lib/tests/components/Widget.test.tsx @@ -1,272 +1,411 @@ -// lib/tests/components/Widget.test.tsx -import { render, screen, waitFor } from '@testing-library/react' +// react/lib/tests/components/Widget.test.tsx +import { render, screen, waitFor, cleanup } from '@testing-library/react' import userEvent from '@testing-library/user-event' import Widget from '../../components/Widget/Widget' +import { TEST_ADDRESSES } from '../util/constants' +import copyToClipboard from 'copy-to-clipboard' +import type { Currency } from '../../util' jest.mock('copy-to-clipboard', () => jest.fn()) -jest.mock('../../util', () => ({ - __esModule: true, - // network / balance - getAddressBalance: jest.fn().mockResolvedValue(0), - // address / currency helpers - isFiat: jest.fn((c: string) => ['USD', 'CAD', 'EUR'].includes(c)), - isValidCashAddress: jest.fn().mockReturnValue(false), - isValidXecAddress: jest.fn().mockReturnValue(true), - getCurrencyTypeFromAddress: jest.fn().mockReturnValue('XEC'), - CURRENCY_PREFIXES_MAP: { - xec: 'ecash', - bch: 'bitcoincash', - }, - CRYPTO_CURRENCIES: ['xec', 'bch'], - DECIMALS: { - XEC: 2, - BCH: 8, - USD: 2, - CAD: 2, - }, - // cashtab / sockets - openCashtabPayment: jest.fn(), - initializeCashtabStatus: jest.fn().mockResolvedValue(false), - setupChronikWebSocket: jest.fn().mockResolvedValue(undefined), - setupAltpaymentSocket: jest.fn().mockResolvedValue(undefined), - // misc helpers - getCurrencyObject: jest.fn((amount: number, currency: string) => ({ - float: Number(amount), - string: String(amount), - currency, - })), - formatPrice: jest.fn((value: number, currency: string) => `${value} ${currency}`), - encodeOpReturnProps: jest.fn(() => 'deadbeef'), - isPropsTrue: (v: unknown) => Boolean(v), - DEFAULT_DONATION_RATE: 0, - DEFAULT_MINIMUM_DONATION_AMOUNT: { - XEC: 10, - BCH: 0.0001, - }, -})) +jest.mock('../../util', () => { + const real = jest.requireActual('../../util') + + return { + __esModule: true, + ...real, + setupChronikWebSocket: jest.fn().mockResolvedValue(undefined), + setupAltpaymentSocket: jest.fn().mockResolvedValue(undefined), + getAddressBalance: jest.fn().mockResolvedValue(0), + openCashtabPayment: jest.fn(), + } +}) jest.mock('../../util/api-client', () => ({ __esModule: true, createPayment: jest.fn(), })) -import copyToClipboard from 'copy-to-clipboard' import { createPayment } from '../../util/api-client' -const ADDRESS = 'ecash:qz3wrtmwtuycud3k6w7afkmn3285vw2lfy36y43nvk' -const API_BASE_URL = 'https://api.example.com' +const CRYPTO_CASES: { label: string; currency: Currency; to: string }[] = [ + { label: 'XEC', currency: 'XEC' as Currency, to: TEST_ADDRESSES.ecash }, + { label: 'BCH', currency: 'BCH' as Currency, to: TEST_ADDRESSES.bitcoincash }, +] + +const FIAT_CASES: { label: string; currency: Currency; price: number, to: string }[] = [ + { label: 'USD', currency: 'USD' as Currency, price: 10, to: TEST_ADDRESSES.ecash }, + { label: 'CAD', currency: 'CAD' as Currency, price: 20, to: TEST_ADDRESSES.ecash}, +] beforeEach(() => { jest.clearAllMocks() + cleanup() }) -describe('Widget – paymentId creation (standalone)', () => { - test('creates paymentId once on initial render when standalone', async () => { - ;(createPayment as jest.Mock).mockResolvedValue('pid-1') - const setPaymentId = jest.fn() - - render( - , - ) - - await waitFor(() => { - expect(createPayment).toHaveBeenCalledTimes(1) - }) - - expect(createPayment).toHaveBeenCalledWith(undefined, ADDRESS, API_BASE_URL) - expect(setPaymentId).toHaveBeenCalledWith('pid-1') - }) - - test('does not create paymentId when isChild is true', async () => { - const setPaymentId = jest.fn() - - render( - , - ) - - await waitFor(() => { - expect(createPayment).not.toHaveBeenCalled() - }) - expect(setPaymentId).not.toHaveBeenCalled() - }) - - test('does not create paymentId when disablePaymentId is true', async () => { - const setPaymentId = jest.fn() - - render( - , - ) - - await waitFor(() => { - expect(createPayment).not.toHaveBeenCalled() - }) - expect(setPaymentId).not.toHaveBeenCalled() - }) - - test('creates paymentId with undefined amount when amount is not provided', async () => { - ;(createPayment as jest.Mock).mockResolvedValue('pid-2') - const setPaymentId = jest.fn() - - render( - , - ) - - await waitFor(() => { - expect(createPayment).toHaveBeenCalledTimes(1) - }) - - expect(createPayment).toHaveBeenCalledWith(undefined, ADDRESS, API_BASE_URL) - }) - - test('creates paymentId with numeric amount when amount is given', async () => { - ;(createPayment as jest.Mock).mockResolvedValue('pid-3') - const setPaymentId = jest.fn() - - render( - , - ) - - await waitFor(() => { - expect(createPayment).toHaveBeenCalledTimes(1) - }) - - expect(createPayment).toHaveBeenCalledWith(10, ADDRESS, API_BASE_URL) - }) - - test('creates new paymentId if amount changes to a different value', async () => { - ;(createPayment as jest.Mock) - .mockResolvedValueOnce('pid-1') - .mockResolvedValueOnce('pid-2') - - const setPaymentId = jest.fn() - - const { rerender } = render( - , - ) - - await waitFor(() => { - expect(createPayment).toHaveBeenCalledTimes(1) - }) - expect(createPayment).toHaveBeenLastCalledWith(5, ADDRESS, API_BASE_URL) - - rerender( - , - ) - - await waitFor(() => { - expect(createPayment).toHaveBeenCalledTimes(2) - }) - expect(createPayment).toHaveBeenLastCalledWith(10, ADDRESS, API_BASE_URL) - }) - - test('does not create new paymentId if amount changes to the same effective value', async () => { - ;(createPayment as jest.Mock).mockResolvedValue('pid-1') - const setPaymentId = jest.fn() - - const { rerender } = render( - , - ) - - await waitFor(() => { - expect(createPayment).toHaveBeenCalledTimes(1) - }) - - rerender( - , - ) - - await waitFor(() => { +describe('Widget – standalone paymentId (crypto)', () => { + test.each(CRYPTO_CASES)( + '%s – first render triggers createPayment(amount)', + async ({ currency, to }) => { + ;(createPayment as jest.Mock).mockResolvedValue('pid-crypto-1') + const setPaymentId = jest.fn() + + render( + + ) + + await waitFor(() => { + expect(createPayment).toHaveBeenCalledWith(5, to, undefined) + }) + } + ) + + test.each(CRYPTO_CASES)( + '%s – amount change triggers new paymentId', + async ({ currency, to }) => { + ;(createPayment as jest.Mock) + .mockResolvedValueOnce('pid-crypto-1') + .mockResolvedValueOnce('pid-crypto-2') + + const setPaymentId = jest.fn() + + const { rerender } = render( + + ) + + await waitFor(() => { + expect(createPayment).toHaveBeenCalledTimes(1) + }) + + rerender( + + ) + + await waitFor(() => { + expect(createPayment).toHaveBeenCalledTimes(2) + }) + expect((createPayment as jest.Mock).mock.calls[1][0]).toBe(8) + } + ) + + test.each(CRYPTO_CASES)( + '%s – same amount across rerender does NOT regenerate', + async ({ currency, to }) => { + ;(createPayment as jest.Mock).mockResolvedValue('pid-crypto') + + const setPaymentId = jest.fn() + + const { rerender } = render( + + ) + + await waitFor(() => { + expect(createPayment).toHaveBeenCalledTimes(1) + }) + + rerender( + + ) + // still only the first call - expect(createPayment).toHaveBeenCalledTimes(1) - }) - }) + await waitFor(() => { + expect(createPayment).toHaveBeenCalledTimes(1) + }) + } + ) + + test.each(CRYPTO_CASES)( + '%s – no paymentId when disablePaymentId=true', + async ({ currency, to }) => { + const setPaymentId = jest.fn() + + render( + + ) + + await waitFor(() => { + expect(createPayment).not.toHaveBeenCalled() + }) + expect(setPaymentId).not.toHaveBeenCalled() + } + ) + + test.each(CRYPTO_CASES)( + '%s – no paymentId when isChild=true', + async ({ currency, to }) => { + const setPaymentId = jest.fn() + + render( + + ) + + await waitFor(() => { + expect(createPayment).not.toHaveBeenCalled() + }) + expect(setPaymentId).not.toHaveBeenCalled() + } + ) + + test.each(CRYPTO_CASES)( + '%s – undefined amount passes undefined to createPayment', + async ({ currency, to }) => { + ;(createPayment as jest.Mock).mockResolvedValue('pid-crypto-undef') + const setPaymentId = jest.fn() + + render( + + ) + + await waitFor(() => { + expect(createPayment).toHaveBeenCalledTimes(1) + }) + expect(createPayment).toHaveBeenCalledWith(undefined, to, undefined) + } + ) +}) + +describe('Widget – standalone paymentId (fiat)', () => { + test.each(FIAT_CASES)( + '%s – uses internal conversion (amount / price) for paymentId', + async ({ currency, price }) => { + ;(createPayment as jest.Mock).mockResolvedValue('pid-fiat-1') + const setPaymentId = jest.fn() + const amount = 50 + + const to = TEST_ADDRESSES.ecash + + render( + + ) + + const expectedCrypto = amount / price + + await waitFor(() => { + expect(createPayment).toHaveBeenCalledWith(expectedCrypto, to, undefined) + }) + } + ) + + test.each(FIAT_CASES)( + '%s – new converted amount regenerates paymentId', + async ({ currency, price }) => { + ;(createPayment as jest.Mock) + .mockResolvedValueOnce('pid-fiat-1') + .mockResolvedValueOnce('pid-fiat-2') + + const setPaymentId = jest.fn() + const to = TEST_ADDRESSES.ecash + + const { rerender } = render( + + ) + + await waitFor(() => { + expect(createPayment).toHaveBeenCalledTimes(1) + }) + + rerender( + + ) + + await waitFor(() => { + expect(createPayment).toHaveBeenCalledTimes(2) + }) + + const lastCall = (createPayment as jest.Mock).mock.calls[1] + expect(lastCall[0]).toBe(90 / price) + expect(lastCall[1]).toBe(to) + } + ) +}) + +describe('Widget – editable amount input (crypto)', () => { + test.each(CRYPTO_CASES)( + '%s – editing amount to a new value regenerates paymentId', + async ({ currency, to }) => { + ;(createPayment as jest.Mock) + .mockResolvedValueOnce('pid-edit-1') + .mockResolvedValueOnce('pid-edit-2') + + const setPaymentId = jest.fn() + const user = userEvent.setup() + + render( + + ) + + await waitFor(() => { + expect(createPayment).toHaveBeenCalledTimes(1) + }) + + const input = screen.getByLabelText(/edit amount/i) + + await user.clear(input) + await user.type(input, '8') + await user.keyboard('{Enter}') + + await waitFor(() => { + expect(createPayment).toHaveBeenCalledTimes(2) + }) + + const lastCall = (createPayment as jest.Mock).mock.calls[1] + expect(lastCall[0]).toBe(8) + expect(lastCall[1]).toBe(to) + } + ) + + test.each(CRYPTO_CASES)( + '%s – editing amount to SAME value does NOT regenerate paymentId', + async ({ currency, to }) => { + ;(createPayment as jest.Mock).mockResolvedValue('pid-edit-same') + + const setPaymentId = jest.fn() + const user = userEvent.setup() + + render( + + ) + + await waitFor(() => { + expect(createPayment).toHaveBeenCalledTimes(1) + }) + + const input = screen.getByLabelText(/edit amount/i) + + await user.clear(input) + await user.type(input, '5') + await user.keyboard('{Enter}') + + // applyDraftAmount should not fire (isSameAmount=true), so no new call + await waitFor(() => { + expect(createPayment).toHaveBeenCalledTimes(1) + }) + } + ) }) -describe('Widget – QR copy behaviour', () => { - test('clicking QR copies payment URL and triggers feedback behaviour', async () => { - const setPaymentId = jest.fn() - - render( - , - ) - - await waitFor(() => { - expect(screen.getByText(/click to copy/i)).toBeTruthy() - }) - - const user = userEvent.setup() - await user.click(screen.getByText(/click to copy/i)) - - expect(copyToClipboard).toHaveBeenCalledTimes(1) - expect((copyToClipboard as jest.Mock).mock.calls[0][0]).toContain('ecash:') - }) +import { act } from 'react-dom/test-utils' + +describe('Widget – QR copy interaction', () => { + test.each([...CRYPTO_CASES, ...FIAT_CASES])( + 'clicking QR copies full URL for %s address', + async ({ currency, to, label }) => { + + await act(async () => { + ;(createPayment as jest.Mock).mockResolvedValue(`pid-qr-${label}`) + ;(copyToClipboard as jest.Mock).mockReturnValue(true) + + const setPaymentId = jest.fn() + + render( + + ) + }) + + const user = userEvent.setup() + await waitFor(() => { + expect(screen.queryByText(/Click to copy/i)).toBeTruthy() + }) + + const qrBox = screen.getByTestId('qr-click-area') + await user.click(qrBox) + + expect(copyToClipboard).toHaveBeenCalledTimes(1) + const copied = (copyToClipboard as jest.Mock).mock.calls[0][0] as string + + if (currency === 'XEC') { + expect(copied).toContain('ecash:') + } else if (currency === 'BCH') { + expect(copied).toContain('bitcoincash:') + } + } + ) }) From 3dab97e3e61f8121694fbd907c555e3fa0a0c55b Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Estev=C3=A3o?= Date: Tue, 9 Dec 2025 00:30:26 -0300 Subject: [PATCH 13/26] fix: dependecy on effect --- react/lib/components/Widget/Widget.tsx | 3 ++- react/lib/tests/util/constants.ts | 5 +++++ 2 files changed, 7 insertions(+), 1 deletion(-) create mode 100644 react/lib/tests/util/constants.ts diff --git a/react/lib/components/Widget/Widget.tsx b/react/lib/components/Widget/Widget.tsx index b89f3cb6..8daf97ef 100644 --- a/react/lib/components/Widget/Widget.tsx +++ b/react/lib/components/Widget/Widget.tsx @@ -959,7 +959,7 @@ export const Widget: React.FunctionComponent = props => { if (!url || !copyToClipboard(url)) return setCopied(true) setRecentlyCopied(true) - }, [disabled, to, url, setCopied, setRecentlyCopied]) + }, [disabled, to, url, setCopied, setRecentlyCopied, qrLoading]) const resolveUrl = useCallback((currency: string, amount?: number) => { if (disabled || !to) return; @@ -1150,6 +1150,7 @@ export const Widget: React.FunctionComponent = props => { )} Date: Wed, 10 Dec 2025 16:33:34 -0300 Subject: [PATCH 14/26] test: working review of paybutton tests --- react/lib/tests/components/PayButton.test.tsx | 880 ++++++------------ 1 file changed, 297 insertions(+), 583 deletions(-) diff --git a/react/lib/tests/components/PayButton.test.tsx b/react/lib/tests/components/PayButton.test.tsx index 92300b24..ee16b335 100644 --- a/react/lib/tests/components/PayButton.test.tsx +++ b/react/lib/tests/components/PayButton.test.tsx @@ -1,8 +1,32 @@ -// FILE: react/lib/tests/components/PayButton.test.tsx +jest.mock('../../util', () => ({ + ...jest.requireActual('../../util'), + getFiatPrice: jest.fn(async () => 100), + setupChronikWebSocket: jest.fn(() => Promise.resolve(undefined)), + setupAltpaymentSocket: jest.fn(() => Promise.resolve(undefined)), + getAddressBalance: jest.fn(async () => 0), + createPayment: jest.fn(async () => '00112233445566778899aabbccddeeff'), +})) import { act, render, screen, waitFor } from '@testing-library/react' import userEvent from '@testing-library/user-event' import { PayButton } from '../../components/PayButton' -import { createPayment } from '../../util/api-client' + +const TEST_ADDRESSES = { + XEC: 'ecash:qz3wrtmwtuycud3k6w7afkmn3285vw2lfy36y43nvk', + BCH: 'bitcoincash:qq7f38meqgctcnywyx74uputa3yuycnv6qr3c6p6rz', +} + +const CRYPTO_CASES = [ + { currency: 'XEC', address: TEST_ADDRESSES.XEC }, + { currency: 'BCH', address: TEST_ADDRESSES.BCH }, +] + +const FIAT_CASES = [ + { currency: 'USD', address: TEST_ADDRESSES.XEC }, + { currency: 'CAD', address: TEST_ADDRESSES.XEC }, +] + +const ALL_CASES = [...CRYPTO_CASES, ...FIAT_CASES] + const realConsoleError = console.error beforeAll(() => { @@ -13,599 +37,289 @@ beforeAll(() => { realConsoleError(...args) } }) - afterAll(() => { console.error = realConsoleError }) -jest.mock('../../util', () => ({ - ...jest.requireActual('../../util'), - getFiatPrice: jest.fn(async () => 100), - setupChronikWebSocket: jest.fn(() => Promise.resolve(undefined)), - setupAltpaymentSocket: jest.fn(() => Promise.resolve(undefined)), -})) -let externalSetConvertedCurrencyObj: any = null; - -jest.mock('../../components/PaymentDialog', () => ({ - PaymentDialog: (props: any) => { - externalSetConvertedCurrencyObj = props.setConvertedCurrencyObj; - return ( -
- - -
- ) - }, -})) +beforeEach(() => { + jest.clearAllMocks() +}) -jest.mock('../../util/api-client', () => ({ - createPayment: jest.fn(async () => 'mock-payment-id'), -})) -describe('PayButton', () => { - beforeEach(() => { - jest.clearAllMocks() - }) - - test('calls onOpen when clicked (crypto path, no timers needed)', async () => { - const user = userEvent.setup() - const onOpen = jest.fn() - - // using XEC will skip waitPrice() - render( - - ) - - await user.click(screen.getByRole('button', { name: /donate/i })) - expect(onOpen).toHaveBeenCalledTimes(1) - }) - - it('calls onOpen when clicked (USD)', async () => { - const user = userEvent.setup() - const onOpen = jest.fn() - - render( - - ) - - // ensure price effect ran (getFiatPrice awaited & setPrice called) - await waitFor(() => { - const { getFiatPrice } = require('../../util') - expect(getFiatPrice).toHaveBeenCalled() - }) - - await user.click(screen.getByRole('button', { name: /donate/i })) - await waitFor(() => expect(onOpen).toHaveBeenCalledTimes(1)) - }) - - it('creates a payment id exactly once for crypto when dialog opens', async () => { - const user = userEvent.setup() - - render( - - ) - - await user.click(screen.getByRole('button', { name: /donate/i })) - - await waitFor(() => { - expect(createPayment).toHaveBeenCalledTimes(1) - }) - - expect(createPayment).toHaveBeenCalledWith( - 1, - 'ecash:qz3wrtmwtuycud3k6w7afkmn3285vw2lfy36y43nvk', - undefined - ) - }) - - it('creates a payment id exactly once for fiat using converted amount', async () => { - const user = userEvent.setup() - - render( - - ) - - // simulate dialog child computing conversion and updating parent - await user.click( - screen.getByRole('button', { name: 'mock-set-converted' }) - ) - - await user.click(screen.getByRole('button', { name: /donate/i })) - - await waitFor(() => { - expect(createPayment).toHaveBeenCalledTimes(1) - }) - - const [amountUsed, addrUsed] = - (createPayment as jest.Mock).mock.calls[0] - - expect(addrUsed).toBe( - 'ecash:qz3wrtmwtuycud3k6w7afkmn3285vw2lfy36y43nvk' - ) - expect(amountUsed).toBeCloseTo(0.12345678, 8) - }) - - it('does not create payment id when disablePaymentId is true', async () => { - const user = userEvent.setup() - - render( - - ) - - await user.click(screen.getByRole('button', { name: /donate/i })) - - await waitFor(() => { - expect(createPayment).not.toHaveBeenCalled() - }) - }) - - it('create payment id when amount is missing', async () => { - const user = userEvent.setup() - - render( - - ) - - await user.click(screen.getByRole('button', { name: /donate/i })) - - await waitFor(() => { - expect(createPayment).toHaveBeenCalledTimes(1) - }) - }) - - it('creates a new payment id when amount changes while dialog is open (crypto)', async () => { - const user = userEvent.setup() - - render( - - ) - - // open dialog: first payment id - await user.click(screen.getByRole('button', { name: /donate/i })) - - await waitFor(() => { - expect(createPayment).toHaveBeenCalledTimes(1) - }) - - // change amount via mocked dialog control - await user.click( - screen.getByRole('button', { name: 'mock-change-amount' }) - ) - - await waitFor(() => { - expect(createPayment).toHaveBeenCalledTimes(2) - }) - - const firstCall = (createPayment as jest.Mock).mock.calls[0] - const secondCall = (createPayment as jest.Mock).mock.calls[1] - - expect(firstCall[0]).toBe(1) - expect(secondCall[0]).toBe(2) - }) - - it('does not create extra payment ids for repeated renders with same effective amount (crypto)', async () => { - const user = userEvent.setup() - - render( - - ) - - // open dialog – first id - await user.click(screen.getByRole('button', { name: /donate/i })) - - await waitFor(() => { - expect(createPayment).toHaveBeenCalledTimes(1) - }) - - // clicking the mock-change-amount twice will move from 2 -> 3 -> 4 - await user.click( - screen.getByRole('button', { name: 'mock-change-amount' }) - ) - await user.click( - screen.getByRole('button', { name: 'mock-change-amount' }) - ) - - await waitFor(() => { - // 2 (initial) + 3 + 4 = 3 distinct effective amounts => 3 calls total - expect(createPayment).toHaveBeenCalledTimes(3) - }) - - const calls = (createPayment as jest.Mock).mock.calls.map( - (c: any[]) => c[0] - ) - - expect(calls).toEqual([2, 3, 4]) - }) - - it('prefers convertedCurrencyObj for fiat and does not create extra ids when only base amount changes', async () => { - const user = userEvent.setup() - - render( - - ) - - // simulate conversion done by dialog - await user.click( - screen.getByRole('button', { name: 'mock-set-converted' }) - ) - - // open dialog (this will use convertedCurrencyObj.float) - await user.click(screen.getByRole('button', { name: /donate/i })) - - await waitFor(() => { - expect(createPayment).toHaveBeenCalledTimes(1) - }) - - const [firstAmount] = (createPayment as jest.Mock).mock.calls[0] - expect(firstAmount).toBeCloseTo(0.12345678, 8) - - // change the base amount via mocked dialog, convertedCurrencyObj remains the same - await user.click( - screen.getByRole('button', { name: 'mock-change-amount' }) - ) - - // effectiveAmount is still the converted one, so no extra call should happen - await waitFor(() => { - expect(createPayment).toHaveBeenCalledTimes(1) - }) - }) - - - it('does not regenerate paymentId when amount is undefined across reopen', async () => { - const user = userEvent.setup() - const onOpen = jest.fn() - - // createPayment calls should start at 0 for this test - expect(createPayment).toHaveBeenCalledTimes(0) - - render( - - ) - - // Still 0 right after render, before opening - expect(createPayment).toHaveBeenCalledTimes(0) - - // First open - await user.click(screen.getByRole('button', { name: /donate/i })) - await waitFor(() => { - expect(createPayment).toHaveBeenCalledTimes(1) - }) - - // Second open (we don't actually need a close button for the paymentId behavior) - await user.click(screen.getByRole('button', { name: /donate/i })) - await waitFor(() => { - expect(createPayment).toHaveBeenCalledTimes(1) - }) - }) - it('does not regenerate paymentId when using fixed numeric amount across reopen', async () => { - const user = userEvent.setup() - const onOpen = jest.fn() - - // sanity check — should start at zero - expect(createPayment).toHaveBeenCalledTimes(0) - - render( - - ) - - // still 0 after mount - expect(createPayment).toHaveBeenCalledTimes(0) - - // first open - await user.click(screen.getByRole('button', { name: /donate/i })) - await waitFor(() => { - expect(createPayment).toHaveBeenCalledTimes(1) - }) - - // second open with same amount - await user.click(screen.getByRole('button', { name: /donate/i })) - await waitFor(() => { - expect(createPayment).toHaveBeenCalledTimes(1) - }) - - // third open: still same amount - await user.click(screen.getByRole('button', { name: /donate/i })) - await waitFor(() => { - expect(createPayment).toHaveBeenCalledTimes(1) - }) - }) - it('regenerates paymentId only when the numeric amount actually changes while dialog is open (crypto)', async () => { - const user = userEvent.setup() - const onOpen = jest.fn() - - expect(createPayment).toHaveBeenCalledTimes(0) - - render( - - ) - - // First open → generate paymentId(2) - await user.click(screen.getByRole('button', { name: /donate/i })) - await waitFor(() => { - expect(createPayment).toHaveBeenCalledTimes(1) - }) - - // Change to 3 → regenerate paymentId - await user.click(screen.getByRole('button', { name: 'mock-change-amount' })) - await waitFor(() => { - expect(createPayment).toHaveBeenCalledTimes(2) - }) - - // Change to 4 → regenerate paymentId again - await user.click(screen.getByRole('button', { name: 'mock-change-amount' })) - await waitFor(() => { - expect(createPayment).toHaveBeenCalledTimes(3) - }) - - // Reopen multiple times at amount=4 → must not regenerate - await user.click(screen.getByRole('button', { name: /donate/i })) - await user.click(screen.getByRole('button', { name: /donate/i })) - await user.click(screen.getByRole('button', { name: /donate/i })) - - await waitFor(() => { - expect(createPayment).toHaveBeenCalledTimes(3) - }) - }) - it('uses convertedCurrencyObj.float as the effective amount for fiat, not the raw amount', async () => { - const user = userEvent.setup() - const onOpen = jest.fn() - - expect(createPayment).toHaveBeenCalledTimes(0) - - render( - - ) - - // simulate conversion (first computed by dialog) - await user.click( - screen.getByRole('button', { name: 'mock-set-converted' }) - ) - - // open dialog - await user.click(screen.getByRole('button', { name: /donate/i })) - - await waitFor(() => { - expect(createPayment).toHaveBeenCalledTimes(1) - }) - - // Check that raw amount=100 was NOT used - const [firstAmountUsed] = (createPayment as jest.Mock).mock.calls[0] - - expect(firstAmountUsed).not.toBe(100) - expect(firstAmountUsed).toBeCloseTo(0.12345678, 8) - }) - it('does not regenerate paymentId for fiat when only raw amount changes and converted stays same', async () => { - const user = userEvent.setup() - const onOpen = jest.fn() - - expect(createPayment).toHaveBeenCalledTimes(0) - - render( - - ) - - // Initial conversion - await user.click(screen.getByRole('button', { name: 'mock-set-converted' })) - - // First open (convertedAmount=0.12345678) - await user.click(screen.getByRole('button', { name: /donate/i })) - await waitFor(() => { - expect(createPayment).toHaveBeenCalledTimes(1) - }) - - // Change raw amount 50 → 51 - await user.click(screen.getByRole('button', { name: 'mock-change-amount' })) - - // no new createPayment - await waitFor(() => { - expect(createPayment).toHaveBeenCalledTimes(1) - }) - }) - it('regenerates paymentId for fiat only when convertedCurrencyObj.float changes', async () => { - const user = userEvent.setup() - - expect(createPayment).toHaveBeenCalledTimes(0) - - render( - - ) - - // First conversion - await user.click(screen.getByRole('button', { name: 'mock-set-converted' })) - - // First open → ID created at 0.12345678 - await user.click(screen.getByRole('button', { name: /donate/i })) - await waitFor(() => { - expect(createPayment).toHaveBeenCalledTimes(1) - }) - - // reset counter - ;(createPayment as jest.Mock).mockClear() - - // Now simulate a different conversion manually - await act(async () => { - externalSetConvertedCurrencyObj({ - float: 0.98765432, - string: '0.98765432', - currency: 'XEC', +// ───────────────────────────────────────────────────────────── +// OPENING & onOpen +// ───────────────────────────────────────────────────────────── +describe('PayButton – onOpen behavior', () => { + + test.each(CRYPTO_CASES)( + 'onOpen executes immediately for crypto (%s)', + async ({ currency, address }) => { + const user = userEvent.setup() + const onOpen = jest.fn() + + render( + + ) + + await user.click(screen.getByRole('button', { name: /donate/i })) + await waitFor(() => { + const { createPayment } = require('../../util'); + expect(createPayment).toHaveBeenCalledTimes(1) }) - }) - - // Now must regenerate - await waitFor(() => { - expect(createPayment).toHaveBeenCalledTimes(1) - }) - }) - it('ensures onOpen always receives a defined paymentId and reuses it across reopen', async () => { - const user = userEvent.setup() - const onOpen = jest.fn() - - render( - - ) - - // First open - await user.click(screen.getByRole('button', { name: /donate/i })) - await waitFor(() => { expect(onOpen).toHaveBeenCalledTimes(1) - }) - - const firstArgs = (onOpen as jest.Mock).mock.calls[0] - expect(firstArgs[2]).toBe('mock-payment-id') - - // Reopen multiple times - await user.click(screen.getByRole('button', { name: /donate/i })) - await user.click(screen.getByRole('button', { name: /donate/i })) - - await waitFor(() => { - expect(onOpen).toHaveBeenCalledTimes(3) - }) - - const secondArgs = (onOpen as jest.Mock).mock.calls[1] - const thirdArgs = (onOpen as jest.Mock).mock.calls[2] - - expect(secondArgs[2]).toBe('mock-payment-id') - expect(thirdArgs[2]).toBe('mock-payment-id') - }) - it('never generates paymentId when disablePaymentId=true', async () => { - const user = userEvent.setup() - - render( - - ) - - await user.click(screen.getByRole('button', { name: /donate/i })) - await user.click(screen.getByRole('button', { name: /donate/i })) - await user.click(screen.getByRole('button', { name: /donate/i })) - - await waitFor(() => { - expect(createPayment).not.toHaveBeenCalled() - }) - }) - it('handles getPaymentId failure without crashing and does not call onOpen with undefined id', async () => { - const user = userEvent.setup() - const onOpen = jest.fn() - - ;(createPayment as jest.Mock).mockImplementationOnce(async () => { - throw new Error('server offline') - }) - - render( - - ) - - await user.click(screen.getByRole('button', { name: /donate/i })) - - await waitFor(() => { + } + ) + + test.each(FIAT_CASES)( + 'onOpen waits for FIAT price before responding (%s)', + async ({ currency, address }) => { + const user = userEvent.setup() + const onOpen = jest.fn() + + render( + + ) + await user.click(screen.getByRole('button', { name: /donate/i })) + await user.click(screen.getByRole('button', { name: /donate/i })) // for some reason first click here in the tests is not doing anything. + expect(onOpen).toHaveBeenCalledTimes(1) - }) - const args = (onOpen as jest.Mock).mock.calls[0] - expect(args[2]).toBeUndefined() - }) + await waitFor(() => { + expect(onOpen).toHaveBeenCalledTimes(1) + }, { timeout: 3000 }) // wIP WTF + await waitFor(() => { + const { getFiatPrice } = require('../../util') + expect(getFiatPrice).toHaveBeenCalledTimes(1) + }) + + await waitFor(() => expect(onOpen).toHaveBeenCalledTimes(1)) + } + ) +}) + + +// ───────────────────────────────────────────────────────────── +// PAYMENT ID CREATION +// ───────────────────────────────────────────────────────────── +describe('PayButton – Payment ID lifecycle', () => { + + test.each(CRYPTO_CASES)( + 'creates payment ID once for crypto open (%s)', + async ({ currency, address }) => { + const user = userEvent.setup() + + render( + + ) + + await user.click(screen.getByRole('button', { name: /donate/i })) + + await waitFor(() => { + const { createPayment } = require('../../util'); + expect(createPayment).toHaveBeenCalledTimes(1) + }) + + const { createPayment } = require('../../util'); + expect(createPayment).toHaveBeenCalledWith(1, address, undefined) + } + ) + + test.each(FIAT_CASES)( + 'payment ID uses converted value for fiat (%s)', + async ({ currency, address }) => { + const user = userEvent.setup() + + render( + + ) + + await user.click(screen.getByRole('button', { name: /donate/i })) + await user.click(screen.getByRole('button', { name: /donate/i })) // for some reason first click here in the tests is not doing anything. + + const { createPayment } = require('../../util'); + await waitFor(() => expect(createPayment).toHaveBeenCalledTimes(1)) + const input = await screen.findByLabelText(/edit amount/i) + + // user types something that triggers conversion + await user.clear(input) + await user.type(input, '100') + await user.click(screen.getByRole('button', { name: /confirm/i })) + + await waitFor(() => expect(createPayment).toHaveBeenCalledTimes(2)) + + const [amountUsed] = (createPayment as jest.Mock).mock.calls[0] + expect(amountUsed).toBeCloseTo(0.05, 8) + const [secondAmountUsed] = (createPayment as jest.Mock).mock.calls[1] + expect(secondAmountUsed).toBeCloseTo(1.0000000, 8) + } + ) + + test.each(ALL_CASES)( + 'no payment ID when disablePaymentId=true (%s)', + async ({ currency, address }) => { + const user = userEvent.setup() + + render( + + ) + + await user.click(screen.getByRole('button', { name: /donate/i })) + await waitFor(() => { + const { createPayment } = require('../../util'); + expect(createPayment).not.toHaveBeenCalled() + }) + } + ) +}) + + + +// ───────────────────────────────────────────────────────────── +// REGENERATION RULES +// ───────────────────────────────────────────────────────────── +describe('PayButton – Payment ID regeneration', () => { + test.each(CRYPTO_CASES)( + 'regenerates only when crypto amount changes while open (%s)', + async ({ currency, address }) => { + const user = userEvent.setup() + + render( + + ) + + // open + await user.click(screen.getByRole('button', { name: /donate/i })) + const { createPayment } = require('../../util'); + await waitFor(() => expect(createPayment).toHaveBeenCalledTimes(1)) + + const input = await screen.findByLabelText(/edit amount/i) + + // 2 → 3 + await user.clear(input) + await user.type(input, '3') + await user.click(screen.getByRole('button', { name: /confirm/i })) + await waitFor(() => expect(createPayment).toHaveBeenCalledTimes(2)) + + // 3 → 4 + await user.clear(input) + await user.type(input, '4') + await user.click(screen.getByRole('button', { name: /confirm/i })) + await waitFor(() => expect(createPayment).toHaveBeenCalledTimes(3)) + + const calls = (createPayment as jest.Mock).mock.calls.map(c => c[0]) + expect(calls).toEqual([2, 3, 4]) + } + ) + + test.each(FIAT_CASES)( + 'for fiat, raw amount changes also regenerate paymentId (%s)', + async ({ currency, address }) => { + const user = userEvent.setup() + + render( + + ) + + // open + await user.click(screen.getByRole('button', { name: /donate/i })) + await user.click(screen.getByRole('button', { name: /donate/i })) // for some reason first click here in the tests is not doing anything. + const { createPayment } = require('../../util'); + await waitFor(() => expect(createPayment).toHaveBeenCalledTimes(1)) + + const input = await screen.findByLabelText(/edit amount/i) + + // change base amount → SHOULD REGEN + await user.clear(input) + await user.type(input, '55') + await user.click(screen.getByRole('button', { name: /confirm/i })) + + await waitFor(() => expect(createPayment).toHaveBeenCalledTimes(2)) + + // Now converted changes — mock fiat price returns different value + const { getFiatPrice } = require('../../util') + getFiatPrice.mockResolvedValueOnce(123) // force new conversion + + await act(async () => { + // trigger recalculation by modifying the input again + await user.clear(input) + await user.type(input, '56') + await user.click(screen.getByRole('button', { name: /confirm/i })) + }) + + await waitFor(() => expect(createPayment).toHaveBeenCalledTimes(3)) + } + ) }) + + +// ───────────────────────────────────────────────────────────── +// FAILURE BEHAVIOR +// ───────────────────────────────────────────────────────────── +describe('PayButton – failure cases', () => { + test.each(CRYPTO_CASES)( + 'if createPayment fails, load forever', + async ({ currency, address }) => { + const user = userEvent.setup() + const onOpen = jest.fn() + + const { createPayment } = require('../../util'); + (createPayment as jest.Mock).mockImplementationOnce(async () => { + throw new Error('server offline') + }) + + render( + + ) + + await user.click(screen.getByRole('button', { name: /donate/i })) + + await waitFor(() => expect(onOpen).toHaveBeenCalledTimes(0)) + + await expect(screen.findByText(/loading/i)).resolves.toBeDefined() + } + ) +}) From 15438436d4e352d6bc269368a086cbeb86aeada8 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Estev=C3=A3o?= Date: Wed, 10 Dec 2025 16:33:41 -0300 Subject: [PATCH 15/26] fix: createPayment import --- react/lib/components/PayButton/PayButton.tsx | 4 ++-- react/lib/components/Widget/Widget.tsx | 3 ++- 2 files changed, 4 insertions(+), 3 deletions(-) diff --git a/react/lib/components/PayButton/PayButton.tsx b/react/lib/components/PayButton/PayButton.tsx index dab49ca8..bb80f027 100644 --- a/react/lib/components/PayButton/PayButton.tsx +++ b/react/lib/components/PayButton/PayButton.tsx @@ -19,9 +19,9 @@ import { setupChronikWebSocket, CryptoCurrency, ButtonSize, - DEFAULT_DONATION_RATE + DEFAULT_DONATION_RATE, + createPayment } from '../../util'; -import { createPayment } from '../../util/api-client'; import { PaymentDialog } from '../PaymentDialog'; import { AltpaymentCoin, AltpaymentError, AltpaymentPair, AltpaymentShift } from '../../altpayment'; export interface PayButtonProps extends ButtonProps { diff --git a/react/lib/components/Widget/Widget.tsx b/react/lib/components/Widget/Widget.tsx index 8daf97ef..c6c48f2c 100644 --- a/react/lib/components/Widget/Widget.tsx +++ b/react/lib/components/Widget/Widget.tsx @@ -41,6 +41,7 @@ import { CryptoCurrency, DEFAULT_DONATION_RATE, DEFAULT_MINIMUM_DONATION_AMOUNT, + createPayment } from '../../util'; import AltpaymentWidget from './AltpaymentWidget' import { @@ -52,7 +53,6 @@ import { MINIMUM_ALTPAYMENT_CAD_AMOUNT, } from '../../altpayment' -import { createPayment } from '../../util/api-client'; export interface WidgetProps { to: string @@ -1241,6 +1241,7 @@ export const Widget: React.FunctionComponent = props => { endAdornment: ( Date: Thu, 11 Dec 2025 11:31:52 -0300 Subject: [PATCH 16/26] test: passing paybutton tests --- react/lib/components/PayButton/PayButton.tsx | 6 +- react/lib/tests/components/PayButton.test.tsx | 69 +++++++++++++++---- 2 files changed, 59 insertions(+), 16 deletions(-) diff --git a/react/lib/components/PayButton/PayButton.tsx b/react/lib/components/PayButton/PayButton.tsx index bb80f027..2a109b3d 100644 --- a/react/lib/components/PayButton/PayButton.tsx +++ b/react/lib/components/PayButton/PayButton.tsx @@ -115,7 +115,7 @@ export const PayButton = ({ const [price, setPrice] = useState(0); const [newTxs, setNewTxs] = useState(); const priceRef = useRef(price); - const cryptoAmountRef = useRef(cryptoAmount); + const cryptoAmountRef = useRef(cryptoAmount); const [paymentId, setPaymentId] = useState(undefined); const [addressType, setAddressType] = useState( @@ -129,8 +129,8 @@ export const PayButton = ({ }, [price]); useEffect(() => { - cryptoAmountRef.current = cryptoAmount; - }, [cryptoAmount]); + cryptoAmountRef.current = convertedCurrencyObj?.float; + }, [cryptoAmount, convertedCurrencyObj]); const getPaymentId = useCallback(async ( diff --git a/react/lib/tests/components/PayButton.test.tsx b/react/lib/tests/components/PayButton.test.tsx index ee16b335..e20ad3bb 100644 --- a/react/lib/tests/components/PayButton.test.tsx +++ b/react/lib/tests/components/PayButton.test.tsx @@ -95,9 +95,6 @@ describe('PayButton – onOpen behavior', () => { expect(onOpen).toHaveBeenCalledTimes(1) - await waitFor(() => { - expect(onOpen).toHaveBeenCalledTimes(1) - }, { timeout: 3000 }) // wIP WTF await waitFor(() => { const { getFiatPrice } = require('../../util') expect(getFiatPrice).toHaveBeenCalledTimes(1) @@ -119,24 +116,53 @@ describe('PayButton – Payment ID lifecycle', () => { 'creates payment ID once for crypto open (%s)', async ({ currency, address }) => { const user = userEvent.setup() + const onOpen = jest.fn() render( ) await user.click(screen.getByRole('button', { name: /donate/i })) + const { createPayment } = require('../../util'); await waitFor(() => { - const { createPayment } = require('../../util'); expect(createPayment).toHaveBeenCalledTimes(1) }) + expect(createPayment).toHaveBeenCalledWith(17, address, undefined); + await waitFor(() => expect(onOpen).toHaveBeenCalledTimes(1)); + (createPayment as jest.Mock).mockResolvedValueOnce('11112233445566778899aabbccddeeff') + + const input = await screen.findByLabelText(/edit amount/i) + await user.clear(input) + await user.type(input, '100') + await user.click(screen.getByRole('button', { name: /confirm/i })) + await waitFor(() => { + expect(createPayment).toHaveBeenCalledTimes(2) + }) + const backdrop = document.querySelector('.MuiBackdrop-root')!; + await user.click(backdrop); + await waitFor(() => + expect(screen.queryByText(/send with.*wallet/i)).toBeNull() + ); + + await user.click(screen.getByRole('button', { name: /donate/i })) + await waitFor(() => expect(onOpen).toHaveBeenCalledTimes(2)) + + const firstCallArgs = (onOpen as jest.Mock).mock.calls[0] + const secondCallArgs = (onOpen as jest.Mock).mock.calls[1] + expect(firstCallArgs[0]).toBeCloseTo(17, 8) + expect(firstCallArgs[1]).toBe(address) + expect(firstCallArgs[2]).toBe('00112233445566778899aabbccddeeff') + expect(Number(secondCallArgs[0])).toBeCloseTo(100.00000000, 8) + expect(secondCallArgs[1]).toBe(address) + expect(secondCallArgs[2]).toBe('11112233445566778899aabbccddeeff') - const { createPayment } = require('../../util'); - expect(createPayment).toHaveBeenCalledWith(1, address, undefined) } ) @@ -144,13 +170,15 @@ describe('PayButton – Payment ID lifecycle', () => { 'payment ID uses converted value for fiat (%s)', async ({ currency, address }) => { const user = userEvent.setup() + const onOpen = jest.fn() render( ) @@ -159,8 +187,10 @@ describe('PayButton – Payment ID lifecycle', () => { const { createPayment } = require('../../util'); await waitFor(() => expect(createPayment).toHaveBeenCalledTimes(1)) - const input = await screen.findByLabelText(/edit amount/i) + await waitFor(() => expect(onOpen).toHaveBeenCalledTimes(1)) + const input = await screen.findByLabelText(/edit amount/i); + (createPayment as jest.Mock).mockResolvedValueOnce('11112233445566778899aabbccddeeff') // user types something that triggers conversion await user.clear(input) await user.type(input, '100') @@ -168,10 +198,23 @@ describe('PayButton – Payment ID lifecycle', () => { await waitFor(() => expect(createPayment).toHaveBeenCalledTimes(2)) - const [amountUsed] = (createPayment as jest.Mock).mock.calls[0] - expect(amountUsed).toBeCloseTo(0.05, 8) - const [secondAmountUsed] = (createPayment as jest.Mock).mock.calls[1] - expect(secondAmountUsed).toBeCloseTo(1.0000000, 8) + const backdrop = document.querySelector('.MuiBackdrop-root')!; + await user.click(backdrop); + await waitFor(() => + expect(screen.queryByText(/send with xec wallet/i)).toBeNull() + ); + + await user.click(screen.getByRole('button', { name: /donate/i })) + await waitFor(() => expect(onOpen).toHaveBeenCalledTimes(2)) + + const firstCallArgs = (onOpen as jest.Mock).mock.calls[0] + const secondCallArgs = (onOpen as jest.Mock).mock.calls[1] + expect(firstCallArgs[0]).toBeCloseTo(10, 8) + expect(firstCallArgs[1]).toBe(address) + expect(firstCallArgs[2]).toBe('00112233445566778899aabbccddeeff') + expect(Number(secondCallArgs[0])).toBeCloseTo(1.0000000, 8) + expect(secondCallArgs[1]).toBe(address) + expect(secondCallArgs[2]).toBe('11112233445566778899aabbccddeeff') } ) From 689127cd960f79ffb1934a5747490d132287c0b0 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Estev=C3=A3o?= Date: Thu, 11 Dec 2025 11:38:40 -0300 Subject: [PATCH 17/26] test: fix warning on widget tests --- react/lib/tests/components/Widget.test.tsx | 3 +-- 1 file changed, 1 insertion(+), 2 deletions(-) diff --git a/react/lib/tests/components/Widget.test.tsx b/react/lib/tests/components/Widget.test.tsx index 52f28eef..62e69643 100644 --- a/react/lib/tests/components/Widget.test.tsx +++ b/react/lib/tests/components/Widget.test.tsx @@ -1,5 +1,6 @@ // react/lib/tests/components/Widget.test.tsx import { render, screen, waitFor, cleanup } from '@testing-library/react' +import { act } from 'react' import userEvent from '@testing-library/user-event' import Widget from '../../components/Widget/Widget' import { TEST_ADDRESSES } from '../util/constants' @@ -365,8 +366,6 @@ describe('Widget – editable amount input (crypto)', () => { ) }) -import { act } from 'react-dom/test-utils' - describe('Widget – QR copy interaction', () => { test.each([...CRYPTO_CASES, ...FIAT_CASES])( 'clicking QR copies full URL for %s address', From 0adb6b9e5c744b02eade89612bff0352d11a0b64 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Estev=C3=A3o?= Date: Thu, 11 Dec 2025 12:11:37 -0300 Subject: [PATCH 18/26] test: add tests for QR code and amount shown --- react/lib/components/Widget/Widget.tsx | 1 + react/lib/tests/components/PayButton.test.tsx | 101 +++++++++++++++++- 2 files changed, 100 insertions(+), 2 deletions(-) diff --git a/react/lib/components/Widget/Widget.tsx b/react/lib/components/Widget/Widget.tsx index c6c48f2c..47b4ade3 100644 --- a/react/lib/components/Widget/Widget.tsx +++ b/react/lib/components/Widget/Widget.tsx @@ -1038,6 +1038,7 @@ export const Widget: React.FunctionComponent = props => { ({ getAddressBalance: jest.fn(async () => 0), createPayment: jest.fn(async () => '00112233445566778899aabbccddeeff'), })) +jest.mock('qrcode.react', () => ({ + QRCodeSVG: ({ value, 'data-testid': tid, imageSettings, fgColor, ...rest }: any) => + require('react').createElement( + 'svg', + { 'data-testid': tid, 'data-value': value, ...rest }, + null + ), +})); + import { act, render, screen, waitFor } from '@testing-library/react' import userEvent from '@testing-library/user-event' import { PayButton } from '../../components/PayButton' @@ -113,7 +122,7 @@ describe('PayButton – onOpen behavior', () => { describe('PayButton – Payment ID lifecycle', () => { test.each(CRYPTO_CASES)( - 'creates payment ID once for crypto open (%s)', + 'onOpen opens with updated paymentId & amount after editing amount (crypto)', async ({ currency, address }) => { const user = userEvent.setup() const onOpen = jest.fn() @@ -167,7 +176,7 @@ describe('PayButton – Payment ID lifecycle', () => { ) test.each(FIAT_CASES)( - 'payment ID uses converted value for fiat (%s)', + 'onOpen opens with updated paymentId & amount after editing amount (fiat)', async ({ currency, address }) => { const user = userEvent.setup() const onOpen = jest.fn() @@ -366,3 +375,91 @@ describe('PayButton – failure cases', () => { } ) }) + + +describe('PayButton – UI shows updated amount + QR after reopen', () => { + test.each(CRYPTO_CASES)( + 'reopens using latest amount and paymentId (crypto) (%s)', + async ({ currency, address }) => { + const user = userEvent.setup() + + render( + + ) + + await user.click(screen.getByRole('button', { name: /donate/i })) + const { createPayment } = require('../../util') + await waitFor(() => expect(createPayment).toHaveBeenCalledTimes(1)) + + const input = await screen.findByLabelText(/edit amount/i) + ;(createPayment as jest.Mock).mockResolvedValueOnce('ffff2233445566778899aabbccddeeff') + + await user.clear(input) + await user.type(input, '1789') + await user.click(screen.getByRole('button', { name: /confirm/i })) + await waitFor(() => expect(createPayment).toHaveBeenCalledTimes(2)) + + const backdrop = document.querySelector('.MuiBackdrop-root')! + await user.click(backdrop) + await waitFor(() => + expect(screen.queryByText(/send with/i)).toBeNull() + ) + + await user.click(screen.getByRole('button', { name: /donate/i })) + await waitFor(() => expect(createPayment).toHaveBeenCalledTimes(2)) + + await expect(screen.findByText(/1,789/)).resolves.toBeDefined() + + const qr = screen.getByTestId('qr-code') + expect(qr.getAttribute('data-value')).toBe(`${address}?amount=1789&op_return_raw=0450415900000010ffff2233445566778899aabbccddeeff`); + } + ) + test.each(FIAT_CASES)( + 'reopens using latest amount and paymentId (fiat) (%s)', + async ({ currency, address }) => { + const user = userEvent.setup() + + render( + + ) + + await user.click(screen.getByRole('button', { name: /donate/i })) + await user.click(screen.getByRole('button', { name: /donate/i })) + const { createPayment } = require('../../util') + await waitFor(() => expect(createPayment).toHaveBeenCalledTimes(1)) + + const input = await screen.findByLabelText(/edit amount/i) + ;(createPayment as jest.Mock).mockResolvedValueOnce('ffff2233445566778899aabbccddeeff') + + await user.clear(input) + await user.type(input, '1789') + await user.click(screen.getByRole('button', { name: /confirm/i })) + await waitFor(() => expect(createPayment).toHaveBeenCalledTimes(2)) + + const backdrop = document.querySelector('.MuiBackdrop-root')! + await user.click(backdrop) + await waitFor(() => + expect(screen.queryByText(/send with/i)).toBeNull() + ) + + await user.click(screen.getByRole('button', { name: /donate/i })) + await waitFor(() => expect(createPayment).toHaveBeenCalledTimes(2)) + + await expect(screen.findByText(/17.89/)).resolves.toBeDefined() + + const qr = screen.getByTestId('qr-code') + expect(qr.getAttribute('data-value')).toBe('ecash:qz3wrtmwtuycud3k6w7afkmn3285vw2lfy36y43nvk?amount=17.89&op_return_raw=0450415900000010ffff2233445566778899aabbccddeeff'); + } + ) +}) + From c8d8f304c82325e5538a282bba57870fc76788e0 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Estev=C3=A3o?= Date: Thu, 11 Dec 2025 12:49:35 -0300 Subject: [PATCH 19/26] fix: type error --- react/lib/components/PayButton/PayButton.tsx | 4 ++-- react/lib/tests/components/PayButton.test.tsx | 2 +- 2 files changed, 3 insertions(+), 3 deletions(-) diff --git a/react/lib/components/PayButton/PayButton.tsx b/react/lib/components/PayButton/PayButton.tsx index 2a109b3d..f8c83981 100644 --- a/react/lib/components/PayButton/PayButton.tsx +++ b/react/lib/components/PayButton/PayButton.tsx @@ -115,7 +115,7 @@ export const PayButton = ({ const [price, setPrice] = useState(0); const [newTxs, setNewTxs] = useState(); const priceRef = useRef(price); - const cryptoAmountRef = useRef(cryptoAmount); + const cryptoAmountRef = useRef(cryptoAmount); const [paymentId, setPaymentId] = useState(undefined); const [addressType, setAddressType] = useState( @@ -129,7 +129,7 @@ export const PayButton = ({ }, [price]); useEffect(() => { - cryptoAmountRef.current = convertedCurrencyObj?.float; + cryptoAmountRef.current = convertedCurrencyObj?.string; }, [cryptoAmount, convertedCurrencyObj]); diff --git a/react/lib/tests/components/PayButton.test.tsx b/react/lib/tests/components/PayButton.test.tsx index 5a738655..5b377d7c 100644 --- a/react/lib/tests/components/PayButton.test.tsx +++ b/react/lib/tests/components/PayButton.test.tsx @@ -218,7 +218,7 @@ describe('PayButton – Payment ID lifecycle', () => { const firstCallArgs = (onOpen as jest.Mock).mock.calls[0] const secondCallArgs = (onOpen as jest.Mock).mock.calls[1] - expect(firstCallArgs[0]).toBeCloseTo(10, 8) + expect(Number(firstCallArgs[0])).toBeCloseTo(10, 8) expect(firstCallArgs[1]).toBe(address) expect(firstCallArgs[2]).toBe('00112233445566778899aabbccddeeff') expect(Number(secondCallArgs[0])).toBeCloseTo(1.0000000, 8) From c88bbc3ecc3934b24f19fb5f784007c27f8df396 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Estev=C3=A3o?= Date: Thu, 11 Dec 2025 16:58:06 -0300 Subject: [PATCH 20/26] fix: widget is actually widget container --- .../lib/components/Widget/WidgetContainer.tsx | 8 +- react/lib/tests/components/Widget.test.tsx | 95 ++++++++++++------- 2 files changed, 64 insertions(+), 39 deletions(-) diff --git a/react/lib/components/Widget/WidgetContainer.tsx b/react/lib/components/Widget/WidgetContainer.tsx index 73f288c0..e0836d23 100644 --- a/react/lib/components/Widget/WidgetContainer.tsx +++ b/react/lib/components/Widget/WidgetContainer.tsx @@ -34,21 +34,21 @@ export interface WidgetContainerProps currencyObj?: CurrencyObject; cryptoAmount?: string; price?: number; - setCurrencyObj: Function; + setCurrencyObj?: Function; randomSatoshis?: boolean | number; hideToasts?: boolean; onSuccess?: (transaction: Transaction) => void; onTransaction?: (transaction: Transaction) => void; sound?: boolean; goalAmount?: number | string; - disabled: boolean; - editable: boolean; + disabled?: boolean; + editable?: boolean; wsBaseUrl?: string; apiBaseUrl?: string; successText?: string; disableAltpayment?: boolean contributionOffset?: number - setNewTxs: Function + setNewTxs?: Function disableSound?: boolean transactionText?: string donationAddress?: string diff --git a/react/lib/tests/components/Widget.test.tsx b/react/lib/tests/components/Widget.test.tsx index 62e69643..3451e039 100644 --- a/react/lib/tests/components/Widget.test.tsx +++ b/react/lib/tests/components/Widget.test.tsx @@ -2,7 +2,7 @@ import { render, screen, waitFor, cleanup } from '@testing-library/react' import { act } from 'react' import userEvent from '@testing-library/user-event' -import Widget from '../../components/Widget/Widget' +import { WidgetContainer as Widget } from '../../components/Widget/WidgetContainer' import { TEST_ADDRESSES } from '../util/constants' import copyToClipboard from 'copy-to-clipboard' import type { Currency } from '../../util' @@ -19,15 +19,10 @@ jest.mock('../../util', () => { setupAltpaymentSocket: jest.fn().mockResolvedValue(undefined), getAddressBalance: jest.fn().mockResolvedValue(0), openCashtabPayment: jest.fn(), + createPayment: jest.fn(), } }) -jest.mock('../../util/api-client', () => ({ - __esModule: true, - createPayment: jest.fn(), -})) - -import { createPayment } from '../../util/api-client' const CRYPTO_CASES: { label: string; currency: Currency; to: string }[] = [ { label: 'XEC', currency: 'XEC' as Currency, to: TEST_ADDRESSES.ecash }, @@ -48,8 +43,8 @@ describe('Widget – standalone paymentId (crypto)', () => { test.each(CRYPTO_CASES)( '%s – first render triggers createPayment(amount)', async ({ currency, to }) => { - ;(createPayment as jest.Mock).mockResolvedValue('pid-crypto-1') - const setPaymentId = jest.fn() + const { createPayment } = require('../../util'); + (createPayment as jest.Mock).mockResolvedValue('pid-crypto-1') render( { amount={5} currency={currency} price={0} - setPaymentId={setPaymentId} /> ) @@ -70,11 +64,11 @@ describe('Widget – standalone paymentId (crypto)', () => { test.each(CRYPTO_CASES)( '%s – amount change triggers new paymentId', async ({ currency, to }) => { + const { createPayment } = require('../../util'); ;(createPayment as jest.Mock) .mockResolvedValueOnce('pid-crypto-1') .mockResolvedValueOnce('pid-crypto-2') - const setPaymentId = jest.fn() const { rerender } = render( { amount={5} currency={currency} price={0} - setPaymentId={setPaymentId} /> ) @@ -96,7 +89,6 @@ describe('Widget – standalone paymentId (crypto)', () => { amount={8} currency={currency} price={0} - setPaymentId={setPaymentId} /> ) @@ -110,9 +102,9 @@ describe('Widget – standalone paymentId (crypto)', () => { test.each(CRYPTO_CASES)( '%s – same amount across rerender does NOT regenerate', async ({ currency, to }) => { + const { createPayment } = require('../../util'); ;(createPayment as jest.Mock).mockResolvedValue('pid-crypto') - const setPaymentId = jest.fn() const { rerender } = render( { amount={5} currency={currency} price={0} - setPaymentId={setPaymentId} /> ) @@ -134,7 +125,6 @@ describe('Widget – standalone paymentId (crypto)', () => { amount={5} currency={currency} price={0} - setPaymentId={setPaymentId} /> ) @@ -148,7 +138,6 @@ describe('Widget – standalone paymentId (crypto)', () => { test.each(CRYPTO_CASES)( '%s – no paymentId when disablePaymentId=true', async ({ currency, to }) => { - const setPaymentId = jest.fn() render( { currency={currency} price={0} disablePaymentId={true} - setPaymentId={setPaymentId} /> ) + // WIP wait for loading to go away + const { createPayment } = require('../../util'); await waitFor(() => { expect(createPayment).not.toHaveBeenCalled() }) - expect(setPaymentId).not.toHaveBeenCalled() } ) test.each(CRYPTO_CASES)( '%s – no paymentId when isChild=true', async ({ currency, to }) => { - const setPaymentId = jest.fn() render( { currency={currency} price={0} isChild={true} - setPaymentId={setPaymentId} /> ) + // WIP wait for loading to go away + const { createPayment } = require('../../util'); await waitFor(() => { expect(createPayment).not.toHaveBeenCalled() }) - expect(setPaymentId).not.toHaveBeenCalled() } ) test.each(CRYPTO_CASES)( '%s – undefined amount passes undefined to createPayment', async ({ currency, to }) => { + const { createPayment } = require('../../util'); ;(createPayment as jest.Mock).mockResolvedValue('pid-crypto-undef') - const setPaymentId = jest.fn() render( ) @@ -218,8 +205,8 @@ describe('Widget – standalone paymentId (fiat)', () => { test.each(FIAT_CASES)( '%s – uses internal conversion (amount / price) for paymentId', async ({ currency, price }) => { + const { createPayment } = require('../../util'); ;(createPayment as jest.Mock).mockResolvedValue('pid-fiat-1') - const setPaymentId = jest.fn() const amount = 50 const to = TEST_ADDRESSES.ecash @@ -230,7 +217,6 @@ describe('Widget – standalone paymentId (fiat)', () => { amount={amount} currency={currency} price={price} - setPaymentId={setPaymentId} /> ) @@ -245,11 +231,11 @@ describe('Widget – standalone paymentId (fiat)', () => { test.each(FIAT_CASES)( '%s – new converted amount regenerates paymentId', async ({ currency, price }) => { + const { createPayment } = require('../../util'); ;(createPayment as jest.Mock) .mockResolvedValueOnce('pid-fiat-1') .mockResolvedValueOnce('pid-fiat-2') - const setPaymentId = jest.fn() const to = TEST_ADDRESSES.ecash const { rerender } = render( @@ -258,7 +244,6 @@ describe('Widget – standalone paymentId (fiat)', () => { amount={50} currency={currency} price={price} - setPaymentId={setPaymentId} /> ) @@ -272,7 +257,6 @@ describe('Widget – standalone paymentId (fiat)', () => { amount={90} currency={currency} price={price} - setPaymentId={setPaymentId} /> ) @@ -291,11 +275,11 @@ describe('Widget – editable amount input (crypto)', () => { test.each(CRYPTO_CASES)( '%s – editing amount to a new value regenerates paymentId', async ({ currency, to }) => { + const { createPayment } = require('../../util'); ;(createPayment as jest.Mock) .mockResolvedValueOnce('pid-edit-1') .mockResolvedValueOnce('pid-edit-2') - const setPaymentId = jest.fn() const user = userEvent.setup() render( @@ -305,7 +289,6 @@ describe('Widget – editable amount input (crypto)', () => { currency={currency} price={0} editable={true} - setPaymentId={setPaymentId} /> ) @@ -332,9 +315,9 @@ describe('Widget – editable amount input (crypto)', () => { test.each(CRYPTO_CASES)( '%s – editing amount to SAME value does NOT regenerate paymentId', async ({ currency, to }) => { + const { createPayment } = require('../../util'); ;(createPayment as jest.Mock).mockResolvedValue('pid-edit-same') - const setPaymentId = jest.fn() const user = userEvent.setup() render( @@ -344,7 +327,6 @@ describe('Widget – editable amount input (crypto)', () => { currency={currency} price={0} editable={true} - setPaymentId={setPaymentId} /> ) @@ -372,10 +354,10 @@ describe('Widget – QR copy interaction', () => { async ({ currency, to, label }) => { await act(async () => { + const { createPayment } = require('../../util'); ;(createPayment as jest.Mock).mockResolvedValue(`pid-qr-${label}`) ;(copyToClipboard as jest.Mock).mockReturnValue(true) - const setPaymentId = jest.fn() render( { amount={10} currency={currency} price={0} - setPaymentId={setPaymentId} /> ) }) @@ -408,3 +389,47 @@ describe('Widget – QR copy interaction', () => { ) }) +describe.skip('Widget – loading behaviour (WIP)', () => { + test.each([...CRYPTO_CASES, ...FIAT_CASES])( + 'handleQrCodeClick does nothing while component is loading (%s)', async ({ currency, to }) => { + const { createPayment } = require('../../util'); + ;(createPayment as jest.Mock).mockImplementation(() => + new Promise(resolve => setTimeout(() => resolve('some-payment-id'), 5000)), + ) + + render( + , + ) + + await waitFor(() => expect(screen.getByText(/loading/i)).toBeTruthy()) + + const user = userEvent.setup() + expect(copyToClipboard).not.toHaveBeenCalled() + await user.click(screen.getByTestId('qr-click-area')) + expect(copyToClipboard).not.toHaveBeenCalled() + }) + + test.each([...CRYPTO_CASES, ...FIAT_CASES])( + 'widget button is disabled while component is loading', async ({ currency, to }) => { + const { createPayment } = require('../../util'); + ;(createPayment as jest.Mock).mockImplementation(() => + new Promise(resolve => setTimeout(() => resolve('some-payment-id'), 5000)), + ) + + render( + , + ) + + await waitFor(() => expect(screen.getByText(/loading/i)).toBeTruthy()) + + const btn = screen.getByRole('button', { name: /send with xec wallet/i }) + expect(btn.hasAttribute('disabled')).toBeTruthy() + }) +}) + From 165f92f1329fcc3b3883734e599c0345104debb6 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Estev=C3=A3o?= Date: Thu, 11 Dec 2025 18:25:33 -0300 Subject: [PATCH 21/26] fix: widget not fetching paymentId for undefined amount --- react/lib/components/Widget/Widget.tsx | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/react/lib/components/Widget/Widget.tsx b/react/lib/components/Widget/Widget.tsx index 47b4ade3..dccc06f5 100644 --- a/react/lib/components/Widget/Widget.tsx +++ b/react/lib/components/Widget/Widget.tsx @@ -599,8 +599,8 @@ export const Widget: React.FunctionComponent = props => { return; } - // For fiat, wait until we have a converted crypto amount - if (isFiat(currency) && convertedCryptoAmount === undefined) { + // For fiat with defined amount, wait until we have a converted crypto amount + if (isFiat(currency) && convertedCryptoAmount === undefined && thisAmount !== undefined ) { return; } From d94939a0b10cee20ff5fb50fb33e4313212d4066 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Estev=C3=A3o?= Date: Thu, 11 Dec 2025 18:25:44 -0300 Subject: [PATCH 22/26] test: complete widget test for now --- react/lib/tests/components/Widget.test.tsx | 57 +++++++++++++++------- 1 file changed, 39 insertions(+), 18 deletions(-) diff --git a/react/lib/tests/components/Widget.test.tsx b/react/lib/tests/components/Widget.test.tsx index 3451e039..dc4093b8 100644 --- a/react/lib/tests/components/Widget.test.tsx +++ b/react/lib/tests/components/Widget.test.tsx @@ -389,31 +389,46 @@ describe('Widget – QR copy interaction', () => { ) }) -describe.skip('Widget – loading behaviour (WIP)', () => { - test.each([...CRYPTO_CASES, ...FIAT_CASES])( - 'handleQrCodeClick does nothing while component is loading (%s)', async ({ currency, to }) => { - const { createPayment } = require('../../util'); - ;(createPayment as jest.Mock).mockImplementation(() => - new Promise(resolve => setTimeout(() => resolve('some-payment-id'), 5000)), +describe('Widget – loading behaviour (WIP)', () => { + const CASES = [...CRYPTO_CASES, ...FIAT_CASES] + + const WITH_AMOUNTS = CASES.flatMap(base => [ + { ...base, amount: 3 }, + { ...base, amount: undefined }, + ]) + + test.each(WITH_AMOUNTS)( + 'handleQrCodeClick does nothing while component is loading (%s, amount=%s)', + async ({ currency, to, amount }) => { + const { createPayment } = require('../../util') + ;(createPayment as jest.Mock).mockImplementation( + () => + new Promise(resolve => + setTimeout(() => resolve('some-payment-id'), 5000), + ), ) - render( - , - ) + render() - await waitFor(() => expect(screen.getByText(/loading/i)).toBeTruthy()) + await waitFor(() => { + expect(screen.getByText(/loading/i)).toBeTruthy() + }) + const qr = await screen.findByTestId('qr-click-area') const user = userEvent.setup() + + await waitFor(() => { + expect(screen.getByText(/loading/i)).toBeTruthy() + }) + expect(copyToClipboard).not.toHaveBeenCalled() - await user.click(screen.getByTestId('qr-click-area')) + await user.click(qr) expect(copyToClipboard).not.toHaveBeenCalled() - }) + }, +) - test.each([...CRYPTO_CASES, ...FIAT_CASES])( - 'widget button is disabled while component is loading', async ({ currency, to }) => { + test.each(WITH_AMOUNTS)( + 'widget button is disabled while component is loading', async ({ currency, to, amount }) => { const { createPayment } = require('../../util'); ;(createPayment as jest.Mock).mockImplementation(() => new Promise(resolve => setTimeout(() => resolve('some-payment-id'), 5000)), @@ -423,12 +438,18 @@ describe.skip('Widget – loading behaviour (WIP)', () => { , ) await waitFor(() => expect(screen.getByText(/loading/i)).toBeTruthy()) - const btn = screen.getByRole('button', { name: /send with xec wallet/i }) + const btn = screen.getByRole('button', { name: /send with.*wallet/i }) + + await waitFor(() => { + expect(screen.getByText(/loading/i)).toBeTruthy() + }) + expect(btn.hasAttribute('disabled')).toBeTruthy() }) }) From 285e0bced94580a83afd867ad3ffddacaffa2b82 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Estev=C3=A3o?= Date: Thu, 11 Dec 2025 18:37:04 -0300 Subject: [PATCH 23/26] fix: double editable --- paybutton/dev/demo/single.html | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/paybutton/dev/demo/single.html b/paybutton/dev/demo/single.html index 43c71c1b..da24f4d1 100644 --- a/paybutton/dev/demo/single.html +++ b/paybutton/dev/demo/single.html @@ -19,7 +19,7 @@
+ on-success="mySuccessFunction" on-transaction="myTransactionFunction" size="lg">