diff --git a/paybutton/dev/demo/single.html b/paybutton/dev/demo/single.html new file mode 100644 index 00000000..da24f4d1 --- /dev/null +++ b/paybutton/dev/demo/single.html @@ -0,0 +1,40 @@ + + + + + + Paybutton Bundle Test + + + + + + + +
+ + diff --git a/react/lib/components/PayButton/PayButton.tsx b/react/lib/components/PayButton/PayButton.tsx index 860e7f0a..f8c83981 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 { @@ -129,17 +129,8 @@ export const PayButton = ({ }, [price]); useEffect(() => { - cryptoAmountRef.current = cryptoAmount; - }, [cryptoAmount]); - - const waitPrice = (callback: Function) => { - const intervalId = setInterval(() => { - if (priceRef.current !== 0) { - clearInterval(intervalId); - callback(); - } - }, 300); - }; + cryptoAmountRef.current = convertedCurrencyObj?.string; + }, [cryptoAmount, convertedCurrencyObj]); const getPaymentId = useCallback(async ( @@ -169,67 +160,94 @@ export const PayButton = ({ [disablePaymentId, apiBaseUrl, randomSatoshis, convertedCurrencyObj] ) - const lastPaymentAmount = useRef(undefined) + const lastPaymentAmount = useRef(undefined); + const lastOnOpenPaymentId = useRef(undefined); + const hasFiredOnOpenRef = useRef(false); + useEffect(() => { + if (!dialogOpen || disablePaymentId || !to) return; - if ( - !dialogOpen || - disablePaymentId || - !to - ) { - return; - } + let effectiveAmount: number | null | undefined; - 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 + effectiveAmount = convertedCurrencyObj?.float ?? null; + } else if (amount !== undefined) { + const parsed = Number(amount); + effectiveAmount = Number.isNaN(parsed) ? null : parsed; } else { - const amountNumber = Number(amount); - if (Number.isNaN(amountNumber)) { - return; - } - effectiveAmount = amountNumber; + effectiveAmount = null; } - if (lastPaymentAmount.current === effectiveAmount) { + + if (effectiveAmount === lastPaymentAmount.current) { return; } lastPaymentAmount.current = effectiveAmount; - void getPaymentId( - currency, - to, - effectiveAmount ?? undefined, - ); - }, [amount, currency, to, dialogOpen, disablePaymentId, paymentId, getPaymentId, convertedCurrencyObj]); + // 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; + } - const handleButtonClick = useCallback(async (): Promise => { + if (disablePaymentId) { + hasFiredOnOpenRef.current = true; + lastOnOpenPaymentId.current = '__no_paymentid__'; - if (onOpen) { if (isFiat(currency)) { - void waitPrice(() => onOpen(cryptoAmountRef.current, to, paymentId)) + onOpen(cryptoAmountRef.current, to, undefined); } else { - onOpen(amount, to, paymentId) + onOpen(amount, to, undefined); } + return; } - setDialogOpen(true) + 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, - ]) + ]); + + + const handleButtonClick = useCallback((): void => { + setDialogOpen(true); + }, []); + const handleCloseDialog = (success?: boolean, paymentId?: string): void => { if (onClose !== undefined) onClose(success, paymentId); @@ -320,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 f332360d..56708093 100644 --- a/react/lib/components/Widget/Widget.tsx +++ b/react/lib/components/Widget/Widget.tsx @@ -41,7 +41,8 @@ import { CryptoCurrency, DEFAULT_DONATION_RATE, DEFAULT_MINIMUM_DONATION_AMOUNT, - darkMode, + createPayment, + darkMode } from '../../util'; import AltpaymentWidget from './AltpaymentWidget' import { @@ -53,7 +54,6 @@ import { MINIMUM_ALTPAYMENT_CAD_AMOUNT, } from '../../altpayment' -import { createPayment } from '../../util/api-client'; export interface WidgetProps { to: string @@ -182,10 +182,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 @@ -599,8 +603,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; } @@ -631,6 +635,8 @@ export const Widget: React.FunctionComponent = props => { } lastEffectiveAmountRef.current = effectiveAmount; + setStandalonePaymentPending(true) + const responsePaymentId = await createPayment( effectiveAmount ?? undefined, to, @@ -639,6 +645,8 @@ export const Widget: React.FunctionComponent = props => { setPaymentId(responsePaymentId); } catch (error) { console.error('Error creating payment ID:', error); + } finally { + setStandalonePaymentPending(false) } }; @@ -1034,6 +1042,7 @@ export const Widget: React.FunctionComponent = props => { = props => { )} = props => { endAdornment: ( = props => { ) : null} - + Powered by PayButton.org {(() => { 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/PayButton.test.tsx b/react/lib/tests/components/PayButton.test.tsx index 5db7dcf7..23735fd0 100644 --- a/react/lib/tests/components/PayButton.test.tsx +++ b/react/lib/tests/components/PayButton.test.tsx @@ -1,293 +1,471 @@ -// FILE: react/lib/tests/components/PayButton.test.tsx -import { 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' - +import { isFiat } from '../../util'; jest.mock('../../util', () => ({ ...jest.requireActual('../../util'), - getFiatPrice: jest.fn(async () => 100), + getFiatPrice: jest.fn(async (currency: string, to: string) => { + if (isFiat(currency)) { + return 100 + } + return null + }), setupChronikWebSocket: jest.fn(() => Promise.resolve(undefined)), setupAltpaymentSocket: jest.fn(() => Promise.resolve(undefined)), + 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' -jest.mock('../../components/PaymentDialog', () => ({ - PaymentDialog: (props: any) => ( -
- - -
- ), -})) +const TEST_ADDRESSES = { + XEC: 'ecash:qz3wrtmwtuycud3k6w7afkmn3285vw2lfy36y43nvk', + BCH: 'bitcoincash:qq7f38meqgctcnywyx74uputa3yuycnv6qr3c6p6rz', +} -jest.mock('../../util/api-client', () => ({ - createPayment: jest.fn(async () => 'mock-payment-id'), -})) +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(() => { + console.error = (...args: any[]) => { + if (args.some(a => typeof a === 'string' && a.includes('Error creating payment ID'))) { + return + } + realConsoleError(...args) + } +}) +afterAll(() => { + console.error = realConsoleError +}) + + +beforeEach(() => { + jest.clearAllMocks() +}) + + +// ───────────────────────────────────────────────────────────── +// 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) + }) + expect(onOpen).toHaveBeenCalledTimes(1) + } + ) + + 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) + + await waitFor(() => { + const { getFiatPrice } = require('../../util') + expect(getFiatPrice).toHaveBeenCalledTimes(1) + }) + + await waitFor(() => expect(onOpen).toHaveBeenCalledTimes(1)) + } + ) +}) -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(() => { + + +// ───────────────────────────────────────────────────────────── +// PAYMENT ID CREATION +// ───────────────────────────────────────────────────────────── +describe('PayButton – Payment ID lifecycle', () => { + + test.each(CRYPTO_CASES)( + 'onOpen opens with updated paymentId & amount after editing amount (crypto)', + 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(() => { + 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') + + } + ) + + test.each(FIAT_CASES)( + 'onOpen opens with updated paymentId & amount after editing amount (fiat)', + 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. + + const { createPayment } = require('../../util'); + await waitFor(() => expect(createPayment).toHaveBeenCalledTimes(1)) + 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') + 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 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(Number(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') + } + ) + + 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') - 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) - }) - }) + 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() + } + ) +}) + + +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'); + } + ) }) diff --git a/react/lib/tests/components/Widget.test.tsx b/react/lib/tests/components/Widget.test.tsx index a9dcfd90..5b5fd2c7 100644 --- a/react/lib/tests/components/Widget.test.tsx +++ b/react/lib/tests/components/Widget.test.tsx @@ -1,272 +1,450 @@ -// 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 { 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' +import { isFiat } from '../../util'; jest.mock('copy-to-clipboard', () => jest.fn()) -jest.mock('../../util', () => ({ - ...jest.requireActual('../../util'), - // 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, + getFiatPrice: jest.fn(async (currency: string, to: string) => { + if (isFiat(currency)) { + return 100 + } + return null + }), + setupChronikWebSocket: jest.fn().mockResolvedValue(undefined), + setupAltpaymentSocket: jest.fn().mockResolvedValue(undefined), + getAddressBalance: jest.fn().mockResolvedValue(0), + openCashtabPayment: jest.fn(), + createPayment: jest.fn(async () => '00112233445566778899aabbccddeeff'), + } +}) -jest.mock('../../util/api-client', () => ({ - __esModule: true, - createPayment: jest.fn(), -})) -import copyToClipboard from 'copy-to-clipboard' -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 }, + { label: 'BCH', currency: 'BCH' as Currency, to: TEST_ADDRESSES.bitcoincash }, +] -const ADDRESS = 'ecash:qz3wrtmwtuycud3k6w7afkmn3285vw2lfy36y43nvk' -const API_BASE_URL = 'https://api.example.com' +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( - , - ) +describe('Widget – standalone paymentId (crypto)', () => { + test.each(CRYPTO_CASES)( + '%s – first render triggers createPayment(amount)', + async ({ currency, to }) => { + const { createPayment } = require('../../util'); + (createPayment as jest.Mock).mockResolvedValue('pid-crypto-1') + + render( + + ) + + await waitFor(() => { + expect(createPayment).toHaveBeenCalledWith(5, to, undefined) + }) + } + ) + + 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 { 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 }) => { + const { createPayment } = require('../../util'); + ;(createPayment as jest.Mock).mockResolvedValue('pid-crypto') + + + const { rerender } = render( + + ) + + await waitFor(() => { + expect(createPayment).toHaveBeenCalledTimes(1) + }) + + rerender( + + ) - 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( - , - ) + // still only the first call + await waitFor(() => { + expect(createPayment).toHaveBeenCalledTimes(1) + }) + } + ) + + test.each(CRYPTO_CASES)( + '%s – no paymentId when disablePaymentId=true', + async ({ currency, to }) => { + + render( + + ) + + // WIP wait for loading to go away + const { createPayment } = require('../../util'); + await waitFor(() => { + expect(createPayment).not.toHaveBeenCalled() + }) + } + ) + + test.each(CRYPTO_CASES)( + '%s – no paymentId when isChild=true', + async ({ currency, to }) => { + + render( + + ) + + // WIP wait for loading to go away + const { createPayment } = require('../../util'); + await waitFor(() => { + expect(createPayment).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') + + render( + + ) + + await waitFor(() => { + expect(createPayment).toHaveBeenCalledTimes(1) + }) + expect(createPayment).toHaveBeenCalledWith(undefined, to, undefined) + } + ) +}) - await waitFor(() => { - expect(createPayment).toHaveBeenCalledTimes(1) - }) +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 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 }) => { + const { createPayment } = require('../../util'); + ;(createPayment as jest.Mock) + .mockResolvedValueOnce('pid-fiat-1') + .mockResolvedValueOnce('pid-fiat-2') + + 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) + } + ) +}) - expect(createPayment).toHaveBeenCalledWith(undefined, ADDRESS, API_BASE_URL) - }) +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'); + + const user = userEvent.setup() + + render( + + ) + + await waitFor(() => { + expect(createPayment).toHaveBeenCalledTimes(1) + }) + + ;(createPayment as jest.Mock).mockResolvedValueOnce('aa112233445566778899aabbccddeeff') + 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 }) => { + const { createPayment } = require('../../util'); + ;(createPayment as jest.Mock).mockResolvedValue('pid-edit-same') + + 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) + }) + } + ) +}) - test('creates paymentId with numeric amount when amount is given', async () => { - ;(createPayment as jest.Mock).mockResolvedValue('pid-3') - const setPaymentId = jest.fn() +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 () => { + const { createPayment } = require('../../util'); + ;(createPayment as jest.Mock).mockResolvedValue(`pid-qr-${label}`) + ;(copyToClipboard as jest.Mock).mockReturnValue(true) + + + 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:') + } + } + ) +}) - render( - , +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'), 1000), + ), ) - 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( - , - ) + render() await waitFor(() => { - expect(createPayment).toHaveBeenCalledTimes(1) + expect(screen.getByText(/loading/i)).toBeTruthy() }) - expect(createPayment).toHaveBeenLastCalledWith(5, ADDRESS, API_BASE_URL) - rerender( - , - ) + const qr = await screen.findByTestId('qr-click-area') + const user = userEvent.setup() await waitFor(() => { - expect(createPayment).toHaveBeenCalledTimes(2) + expect(screen.getByText(/loading/i)).toBeTruthy() }) - 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() + expect(copyToClipboard).not.toHaveBeenCalled() + await user.click(qr) + expect(copyToClipboard).not.toHaveBeenCalled() + }, +) - const { rerender } = render( - , + 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'), 1000)), ) - await waitFor(() => { - expect(createPayment).toHaveBeenCalledTimes(1) - }) - - rerender( + render( , ) - await waitFor(() => { - // still only the first call - expect(createPayment).toHaveBeenCalledTimes(1) - }) - }) -}) + await waitFor(() => expect(screen.getByText(/loading/i)).toBeTruthy()) -describe('Widget – QR copy behaviour', () => { - test('clicking QR copies payment URL and triggers feedback behaviour', async () => { - const setPaymentId = jest.fn() - - render( - , - ) + const btn = screen.getByRole('button', { name: /send with.*wallet/i }) await waitFor(() => { - expect(screen.getByText(/click to copy/i)).toBeTruthy() + expect(screen.getByText(/loading/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:') + expect(btn.hasAttribute('disabled')).toBeTruthy() }) }) diff --git a/react/lib/tests/util/constants.ts b/react/lib/tests/util/constants.ts new file mode 100644 index 00000000..33f70e62 --- /dev/null +++ b/react/lib/tests/util/constants.ts @@ -0,0 +1,5 @@ +export const TEST_ADDRESSES = { + ecash: 'ecash:qz3wrtmwtuycud3k6w7afkmn3285vw2lfy36y43nvk', + bitcoincash: 'bitcoincash:qq7f38meqgctcnywyx74uputa3yuycnv6qr3c6p6rz' +} + diff --git a/react/package.json b/react/package.json index 622e7823..e4d1e7e6 100644 --- a/react/package.json +++ b/react/package.json @@ -56,6 +56,7 @@ "@vitejs/plugin-react": "^4.6.0", "babel-eslint": "10.1.0", "babel-loader": "8.2.5", + "canvas": "^3.2.0", "concurrently": "5.3.0", "cross-env": "7.0.3", "currency-formatter": "1.5.9", diff --git a/react/yarn.lock b/react/yarn.lock index 0c51669e..089d2919 100644 --- a/react/yarn.lock +++ b/react/yarn.lock @@ -5563,6 +5563,14 @@ caniuse-lite@^1.0.0, caniuse-lite@^1.0.30001702, caniuse-lite@^1.0.30001746: resolved "https://registry.yarnpkg.com/caniuse-lite/-/caniuse-lite-1.0.30001751.tgz#dacd5d9f4baeea841641640139d2b2a4df4226ad" integrity sha512-A0QJhug0Ly64Ii3eIqHu5X51ebln3k4yTUkY1j8drqpWHVreg/VLijN48cZ1bYPiqOQuqpkIKnzr/Ul8V+p6Cw== +canvas@^3.2.0: + version "3.2.0" + resolved "https://registry.yarnpkg.com/canvas/-/canvas-3.2.0.tgz#877c51aabdb99cbb5b2b378138a6cdd681e9d390" + integrity sha512-jk0GxrLtUEmW/TmFsk2WghvgHe8B0pxGilqCL21y8lHkPUGa6FTsnCNtHPOzT8O3y+N+m3espawV80bbBlgfTA== + dependencies: + node-addon-api "^7.0.0" + prebuild-install "^7.1.3" + case-sensitive-paths-webpack-plugin@^2.4.0: version "2.4.0" resolved "https://registry.yarnpkg.com/case-sensitive-paths-webpack-plugin/-/case-sensitive-paths-webpack-plugin-2.4.0.tgz#db64066c6422eed2e08cc14b986ca43796dbc6d4" @@ -6384,6 +6392,13 @@ decimal.js@*, decimal.js@^10.4.2, decimal.js@^10.6.0: resolved "https://registry.yarnpkg.com/decimal.js/-/decimal.js-10.6.0.tgz#e649a43e3ab953a72192ff5983865e509f37ed9a" integrity sha512-YpgQiITW3JXGntzdUmyUR1V812Hn8T1YVXhCu+wO3OpS4eU9l4YdD3qjyiKdV6mvV29zapkMeD390UVEf2lkUg== +decompress-response@^6.0.0: + version "6.0.0" + resolved "https://registry.yarnpkg.com/decompress-response/-/decompress-response-6.0.0.tgz#ca387612ddb7e104bd16d85aab00d5ecf09c66fc" + integrity sha512-aW35yZM6Bb/4oJlZncMH2LCoZtJXTRxES17vE3hoRiowU2kWHaJKFkSBDnDR+cm9J+9QhXmREyIfv0pji9ejCQ== + dependencies: + mimic-response "^3.1.0" + dedent@^0.7.0: version "0.7.0" resolved "https://registry.yarnpkg.com/dedent/-/dedent-0.7.0.tgz#2495ddbaf6eb874abb0e1be9df22d2e5a544326c" @@ -6394,6 +6409,11 @@ dedent@^1.0.0: resolved "https://registry.yarnpkg.com/dedent/-/dedent-1.7.0.tgz#c1f9445335f0175a96587be245a282ff451446ca" integrity sha512-HGFtf8yhuhGhqO07SV79tRp+br4MnbdjeVxotpn1QBl30pcLLCQjX5b2295ll0fv8RKDKsmWYrl05usHM9CewQ== +deep-extend@^0.6.0: + version "0.6.0" + resolved "https://registry.yarnpkg.com/deep-extend/-/deep-extend-0.6.0.tgz#c4fa7c95404a17a9c3e8ca7e1537312b736330ac" + integrity sha512-LOHxIOaPYdHlJRtCQfDIVZtfw/ufM8+rVj649RIHzcm/vGwQRXFt6OPqIFWsm2XEMrNIEtWR64sY1LEKD2vAOA== + deep-is@^0.1.3, deep-is@~0.1.3: version "0.1.4" resolved "https://registry.yarnpkg.com/deep-is/-/deep-is-0.1.4.tgz#a6f2dce612fadd2ef1f519b73551f17e85199831" @@ -6498,6 +6518,11 @@ detect-indent@^6.1.0: resolved "https://registry.yarnpkg.com/detect-indent/-/detect-indent-6.1.0.tgz#592485ebbbf6b3b1ab2be175c8393d04ca0d57e6" integrity sha512-reYkTUJAZb9gUuZ2RvVCNhVHdg62RHnJ7WJl8ftMi4diZ6NWlciOzQN88pUhSELEwflJht4oQDv0F0BMlwaYtA== +detect-libc@^2.0.0: + version "2.1.2" + resolved "https://registry.yarnpkg.com/detect-libc/-/detect-libc-2.1.2.tgz#689c5dcdc1900ef5583a4cb9f6d7b473742074ad" + integrity sha512-Btj2BOOO83o3WyH59e8MgXsxEQVcarkUOpEYrubB0urwnN10yQ364rsiByU11nZlqWYZm05i/of7io4mzihBtQ== + detect-newline@^3.0.0: version "3.1.0" resolved "https://registry.yarnpkg.com/detect-newline/-/detect-newline-3.1.0.tgz#576f5dfc63ae1a192ff192d8ad3af6308991b651" @@ -7615,6 +7640,11 @@ exit@^0.1.2: resolved "https://registry.yarnpkg.com/exit/-/exit-0.1.2.tgz#0632638f8d877cc82107d30a0fff1a17cba1cd0c" integrity sha512-Zk/eNKV2zbjpKzrsQ+n1G6poVbErQxJ0LBOJXaKZ1EViLzH+hrLu9cdXI4zw9dBQJslwBEpbQ2P1oS7nDxs6jQ== +expand-template@^2.0.3: + version "2.0.3" + resolved "https://registry.yarnpkg.com/expand-template/-/expand-template-2.0.3.tgz#6e14b3fcee0f3a6340ecb57d2e8918692052a47c" + integrity sha512-XYfuKMvj4O35f/pOXLObndIRvyQ+/+6AhODh+OKWj9S9498pHHn/IMszH+gt0fBCRWMNfk1ZSp5x3AifmnI2vg== + expect@^27.5.1: version "27.5.1" resolved "https://registry.yarnpkg.com/expect/-/expect-27.5.1.tgz#83ce59f1e5bdf5f9d2b94b61d2050db48f3fef74" @@ -8186,6 +8216,11 @@ giget@^1.0.0: pathe "^2.0.3" tar "^6.2.1" +github-from-package@0.0.0: + version "0.0.0" + resolved "https://registry.yarnpkg.com/github-from-package/-/github-from-package-0.0.0.tgz#97fb5d96bfde8973313f20e8288ef9a167fa64ce" + integrity sha512-SyHy3T1v2NUXn29OsWdxmK6RwHD+vkj3v8en8AOBZ1wBQ/hCAQ5bAQTD02kW4W9tUp/3Qh6J8r9EvntiyCmOOw== + github-slugger@^1.0.0: version "1.5.0" resolved "https://registry.yarnpkg.com/github-slugger/-/github-slugger-1.5.0.tgz#17891bbc73232051474d68bd867a34625c955f7d" @@ -8741,7 +8776,7 @@ inherits@2.0.3: resolved "https://registry.yarnpkg.com/inherits/-/inherits-2.0.3.tgz#633c2c83e3da42a502f52466022480f4208261de" integrity sha512-x00IRNXNy63jwGkJmzPigoySHbaqpNuzKbBOmzK+g2OdZpQ9w+sxCN+VSB3ja7IAge2OP2qpfxTjeNcyjmW1uw== -ini@^1.3.5: +ini@^1.3.5, ini@~1.3.0: version "1.3.8" resolved "https://registry.yarnpkg.com/ini/-/ini-1.3.8.tgz#a29da425b48806f34767a4efce397269af28432c" integrity sha512-JV/yugV2uzW5iMRSiZAyDtQd+nxtUnjeLt0acNdw98kKLrvuRVyB80tsREOE7yvGVgalhZ6RNXCmEHkUKBKxew== @@ -10751,6 +10786,11 @@ mimic-fn@^2.1.0: resolved "https://registry.yarnpkg.com/mimic-fn/-/mimic-fn-2.1.0.tgz#7ed2c2ccccaf84d3ffcb7a69b57711fc2083401b" integrity sha512-OqbOk5oEQeAZ8WXWydlu9HJjz9WVdEIvamMCcXmuqUYjTknH/sqsWvhQ3vgwKFRR1HpjvNBKQ37nbJgYzGqGcg== +mimic-response@^3.1.0: + version "3.1.0" + resolved "https://registry.yarnpkg.com/mimic-response/-/mimic-response-3.1.0.tgz#2d1d59af9c1b129815accc2c46a022a5ce1fa3c9" + integrity sha512-z0yWI+4FDrrweS8Zmt4Ej5HdJmky15+L2e6Wgn3+iK5fWzb6T3fhNFq2+MeTRb064c6Wr4N/wv0DzQTjNzHNGQ== + mini-css-extract-plugin@^2.4.5: version "2.9.4" resolved "https://registry.yarnpkg.com/mini-css-extract-plugin/-/mini-css-extract-plugin-2.9.4.tgz#cafa1a42f8c71357f49cd1566810d74ff1cb0200" @@ -10792,7 +10832,7 @@ minimatch@^9.0.4: dependencies: brace-expansion "^2.0.1" -minimist@^1.2.0, minimist@^1.2.5, minimist@^1.2.6: +minimist@^1.2.0, minimist@^1.2.3, minimist@^1.2.5, minimist@^1.2.6: version "1.2.8" resolved "https://registry.yarnpkg.com/minimist/-/minimist-1.2.8.tgz#c1a464e7693302e082a075cee0c057741ac4772c" integrity sha512-2yyAR8qBkN3YuheJanUpWC5U3bb5osDywNB8RzDVlDwDHbocAJveqqj1u8+SVD7jkWT4yvsHCpWqqWqAxb0zCA== @@ -10822,7 +10862,7 @@ minizlib@^2.1.1: minipass "^3.0.0" yallist "^4.0.0" -mkdirp-classic@^0.5.2: +mkdirp-classic@^0.5.2, mkdirp-classic@^0.5.3: version "0.5.3" resolved "https://registry.yarnpkg.com/mkdirp-classic/-/mkdirp-classic-0.5.3.tgz#fa10c9115cc6d8865be221ba47ee9bed78601113" integrity sha512-gKLcREMhtuZRwRAfqP3RFW+TK4JqApVBtOIftVgjuABpAtpxhPGaDcfvbhNvD0B8iD1oUr/txX35NjcaY6Ns/A== @@ -10886,6 +10926,11 @@ nanoid@^3.3.11: resolved "https://registry.yarnpkg.com/nanoid/-/nanoid-3.3.11.tgz#4f4f112cefbe303202f2199838128936266d185b" integrity sha512-N8SpfPUnUp1bK+PMYW8qSWdl9U+wwNWI4QKxOYDy9JAro3WMX7p2OeVRF9v+347pnakNevPmiHhNmZ2HbFA76w== +napi-build-utils@^2.0.0: + version "2.0.0" + resolved "https://registry.yarnpkg.com/napi-build-utils/-/napi-build-utils-2.0.0.tgz#13c22c0187fcfccce1461844136372a47ddc027e" + integrity sha512-GEbrYkbfF7MoNaoh2iGG84Mnf/WZfB0GdGEsM8wz7Expx/LlWf5U8t9nvJKXSp3qr5IsEbK04cBGhol/KwOsWA== + natural-compare-lite@^1.4.0: version "1.4.0" resolved "https://registry.yarnpkg.com/natural-compare-lite/-/natural-compare-lite-1.4.0.tgz#17b09581988979fddafe0201e931ba933c96cbb4" @@ -10919,6 +10964,18 @@ no-case@^3.0.4: lower-case "^2.0.2" tslib "^2.0.3" +node-abi@^3.3.0: + version "3.85.0" + resolved "https://registry.yarnpkg.com/node-abi/-/node-abi-3.85.0.tgz#b115d575e52b2495ef08372b058e13d202875a7d" + integrity sha512-zsFhmbkAzwhTft6nd3VxcG0cvJsT70rL+BIGHWVq5fi6MwGrHwzqKaxXE+Hl2GmnGItnDKPPkO5/LQqjVkIdFg== + dependencies: + semver "^7.3.5" + +node-addon-api@^7.0.0: + version "7.1.1" + resolved "https://registry.yarnpkg.com/node-addon-api/-/node-addon-api-7.1.1.tgz#1aba6693b0f255258a049d621329329322aad558" + integrity sha512-5m3bsyrjFWE1xf7nz7YXdN4udnVtXK6/Yfgn5qnahL6bCkf2yKt4k3nuTKAtT4r3IG8JNR2ncsIMdZuAzJjHQQ== + node-dir@^0.1.17: version "0.1.17" resolved "https://registry.yarnpkg.com/node-dir/-/node-dir-0.1.17.tgz#5f5665d93351335caabef8f1c554516cf5f1e4e5" @@ -12152,6 +12209,24 @@ postcss@^8.2.1, postcss@^8.3.5, postcss@^8.4.27, postcss@^8.4.33, postcss@^8.4.4 picocolors "^1.1.1" source-map-js "^1.2.1" +prebuild-install@^7.1.3: + version "7.1.3" + resolved "https://registry.yarnpkg.com/prebuild-install/-/prebuild-install-7.1.3.tgz#d630abad2b147443f20a212917beae68b8092eec" + integrity sha512-8Mf2cbV7x1cXPUILADGI3wuhfqWvtiLA1iclTDbFRZkgRQS0NqsPZphna9V+HyTEadheuPmjaJMsbzKQFOzLug== + dependencies: + detect-libc "^2.0.0" + expand-template "^2.0.3" + github-from-package "0.0.0" + minimist "^1.2.3" + mkdirp-classic "^0.5.3" + napi-build-utils "^2.0.0" + node-abi "^3.3.0" + pump "^3.0.0" + rc "^1.2.7" + simple-get "^4.0.0" + tar-fs "^2.0.0" + tunnel-agent "^0.6.0" + prelude-ls@^1.2.1: version "1.2.1" resolved "https://registry.yarnpkg.com/prelude-ls/-/prelude-ls-1.2.1.tgz#debc6489d7a6e6b0e7611888cec880337d316396" @@ -12446,6 +12521,16 @@ raw-body@2.5.2: iconv-lite "0.4.24" unpipe "1.0.0" +rc@^1.2.7: + version "1.2.8" + resolved "https://registry.yarnpkg.com/rc/-/rc-1.2.8.tgz#cd924bf5200a075b83c188cd6b9e211b7fc0d3ed" + integrity sha512-y3bGgqKj3QBdxLbLkomlohkvsA8gdAiUQlSBJnBhfn+BPxg4bc62d8TcBW15wavDfgexCgccckhcZvywyQYPOw== + dependencies: + deep-extend "^0.6.0" + ini "~1.3.0" + minimist "^1.2.0" + strip-json-comments "~2.0.1" + react-app-polyfill@^3.0.0: version "3.0.0" resolved "https://registry.yarnpkg.com/react-app-polyfill/-/react-app-polyfill-3.0.0.tgz#95221e0a9bd259e5ca6b177c7bb1cb6768f68fd7" @@ -13467,6 +13552,20 @@ signal-exit@^4.0.1: resolved "https://registry.yarnpkg.com/signal-exit/-/signal-exit-4.1.0.tgz#952188c1cbd546070e2dd20d0f41c0ae0530cb04" integrity sha512-bzyZ1e88w9O1iNJbKnOlvYTrWPDl46O1bG0D3XInv+9tkPrxrN8jUUTiFlDkkmKWgn1M6CfIA13SuGqOa9Korw== +simple-concat@^1.0.0: + version "1.0.1" + resolved "https://registry.yarnpkg.com/simple-concat/-/simple-concat-1.0.1.tgz#f46976082ba35c2263f1c8ab5edfe26c41c9552f" + integrity sha512-cSFtAPtRhljv69IK0hTVZQ+OfE9nePi/rtJmw5UjHeVyVroEqJXP1sFztKUy1qU+xvz3u/sfYJLa947b7nAN2Q== + +simple-get@^4.0.0: + version "4.0.1" + resolved "https://registry.yarnpkg.com/simple-get/-/simple-get-4.0.1.tgz#4a39db549287c979d352112fa03fd99fd6bc3543" + integrity sha512-brv7p5WgH0jmQJr1ZDDfKDOSeWWg+OVypG99A/5vYGPqJ6pxiaHLy8nxtFjBA7oMa01ebA9gfh1uMCFqOuXxvA== + dependencies: + decompress-response "^6.0.0" + once "^1.3.1" + simple-concat "^1.0.0" + sisteransi@^1.0.5: version "1.0.5" resolved "https://registry.yarnpkg.com/sisteransi/-/sisteransi-1.0.5.tgz#134d681297756437cc05ca01370d3a7a571075ed" @@ -13974,6 +14073,11 @@ strip-json-comments@^3.0.1, strip-json-comments@^3.1.1: resolved "https://registry.yarnpkg.com/strip-json-comments/-/strip-json-comments-3.1.1.tgz#31f1281b3832630434831c310c01cccda8cbe006" integrity sha512-6fPc+R4ihwqP6N/aIv2f1gMH8lOVtWQHoqC4yK6oSDVVocumAsfCqjkXnqiYMhmMwS/mEHLp7Vehlt3ql6lEig== +strip-json-comments@~2.0.1: + version "2.0.1" + resolved "https://registry.yarnpkg.com/strip-json-comments/-/strip-json-comments-2.0.1.tgz#3c531942e908c2697c0ec344858c286c7ca0a60a" + integrity sha512-4gB8na07fecVVkOI6Rs4e7T6NOTki5EmL7TUduTs6bu3EdnSycntVJ4re8kgZA+wx9IueI2Y11bfbgwtzuE0KQ== + style-inject@^0.3.0: version "0.3.0" resolved "https://registry.yarnpkg.com/style-inject/-/style-inject-0.3.0.tgz#d21c477affec91811cc82355832a700d22bf8dd3" @@ -14141,7 +14245,7 @@ tapable@^2.0.0, tapable@^2.2.0, tapable@^2.2.1, tapable@^2.3.0: resolved "https://registry.yarnpkg.com/tapable/-/tapable-2.3.0.tgz#7e3ea6d5ca31ba8e078b560f0d83ce9a14aa8be6" integrity sha512-g9ljZiwki/LfxmQADO3dEY1CbpmXT5Hm2fJ+QaGKwSXUylMybePR7/67YW7jOrrvjEgL1Fmz5kzyAjWVWLlucg== -tar-fs@^2.1.1: +tar-fs@^2.0.0, tar-fs@^2.1.1: version "2.1.4" resolved "https://registry.yarnpkg.com/tar-fs/-/tar-fs-2.1.4.tgz#800824dbf4ef06ded9afea4acafe71c67c76b930" integrity sha512-mDAjwmZdh7LTT6pNleZ05Yt65HC3E+NiQzl672vQG38jIrehtJk/J3mNwIg+vShQPcLF/LV7CMnDW6vjj6sfYQ== @@ -14444,6 +14548,13 @@ tsutils@^3.21.0: dependencies: tslib "^1.8.1" +tunnel-agent@^0.6.0: + version "0.6.0" + resolved "https://registry.yarnpkg.com/tunnel-agent/-/tunnel-agent-0.6.0.tgz#27a5dea06b36b04a0a9966774b290868f0fc40fd" + integrity sha512-McnNiV1l8RYeY8tBgEpuodCC1mLUdbSN+CYBL7kJsJNInOP8UjDDEwdk6Mw60vdLLrr5NHKZhMAOSrR2NZuQ+w== + dependencies: + safe-buffer "^5.0.1" + type-check@^0.4.0, type-check@~0.4.0: version "0.4.0" resolved "https://registry.yarnpkg.com/type-check/-/type-check-0.4.0.tgz#07b8203bfa7056c0657050e3ccd2c37730bab8f1"