diff --git a/package.json b/package.json index 11799677a..f301ae129 100644 --- a/package.json +++ b/package.json @@ -10,7 +10,8 @@ "build": "yarn workspace app build", "start": "yarn workspace app start", "audit:ci": "audit-ci --config ./audit-ci.jsonc", - "test:ci": "yarn workspace arb-token-bridge-ui test:ci", + "test:ci": "yarn workspace arb-token-bridge-ui test:ci && yarn workspace arb-token-bridge-ui test:integration", + "test:integration": "yarn workspace arb-token-bridge-ui test:integration", "prettier:check": "./node_modules/.bin/prettier --check .", "prettier:format": "./node_modules/.bin/prettier --write .", "lint": "tsc && eslint", diff --git a/packages/app/src/utils/__tests__/sanitizeAndRedirect.test.ts b/packages/app/src/utils/__tests__/sanitizeAndRedirect.test.ts new file mode 100644 index 000000000..6d5138cb9 --- /dev/null +++ b/packages/app/src/utils/__tests__/sanitizeAndRedirect.test.ts @@ -0,0 +1,86 @@ +import { beforeEach, describe, expect, it, vi } from 'vitest'; + +import { PathnameEnum } from '@/bridge/constants'; + +import { initializeBridgePage } from '../bridgePageUtils'; + +const { redirectMock, registerLocalNetworkMock } = vi.hoisted(() => { + process.env.NEXT_PUBLIC_INFURA_KEY ||= 'test-infura-key'; + process.env.NEXT_PUBLIC_FEATURE_FLAG_LIFI = 'true'; + + return { + redirectMock: vi.fn(), + registerLocalNetworkMock: vi.fn(async () => undefined), + }; +}); + +vi.mock('next/navigation', () => ({ + redirect: redirectMock, +})); + +vi.mock('@/bridge/util/networks', async (importActual) => { + const actual = await importActual(); + + return { + ...actual, + registerLocalNetwork: registerLocalNetworkMock, + }; +}); + +const ZERO_ADDRESS = '0x0000000000000000000000000000000000000000'; + +function getRedirectedUrl() { + const [redirectTarget] = redirectMock.mock.calls.at(-1) ?? []; + + if (!redirectTarget) { + throw new Error('Expected redirect to be called.'); + } + + return new URL(redirectTarget, 'https://example.com'); +} + +describe('initializeBridgePage sanitization', () => { + beforeEach(() => { + redirectMock.mockClear(); + registerLocalNetworkMock.mockClear(); + }); + + it('sets destinationToken to zero address for first-load apechain -> superposition', async () => { + await initializeBridgePage({ + searchParams: { + sourceChain: 'apechain', + destinationChain: 'superposition', + }, + redirectPath: PathnameEnum.BRIDGE, + }); + + expect(redirectMock).toHaveBeenCalledTimes(1); + const redirected = getRedirectedUrl(); + + expect(redirected.pathname).toBe(PathnameEnum.BRIDGE); + expect(redirected.searchParams.get('sourceChain')).toBe('apechain'); + expect(redirected.searchParams.get('destinationChain')).toBe('superposition'); + expect(redirected.searchParams.get('destinationToken')).toBe(ZERO_ADDRESS); + expect(redirected.searchParams.get('sanitized')).toBe('true'); + }); + + it('sets token and destinationToken to zero address for first-load superposition -> apechain', async () => { + await initializeBridgePage({ + searchParams: { + sourceChain: 'superposition', + destinationChain: 'apechain', + }, + redirectPath: PathnameEnum.BRIDGE, + }); + + expect(redirectMock).toHaveBeenCalledTimes(1); + const redirected = getRedirectedUrl(); + + expect(redirected.pathname).toBe(PathnameEnum.BRIDGE); + expect(redirected.searchParams.get('sourceChain')).toBe('superposition'); + expect(redirected.searchParams.get('destinationChain')).toBe('apechain'); + expect(redirected.searchParams.get('token')).toBe(ZERO_ADDRESS); + expect(redirected.searchParams.get('destinationToken')).toBe(ZERO_ADDRESS); + expect(redirected.searchParams.get('sanitized')).toBe('true'); + }); +}); diff --git a/packages/app/src/utils/bridgePageUtils.tsx b/packages/app/src/utils/bridgePageUtils.tsx index 0ea49527c..9432de3f7 100644 --- a/packages/app/src/utils/bridgePageUtils.tsx +++ b/packages/app/src/utils/bridgePageUtils.tsx @@ -1,8 +1,10 @@ +import { redirect } from 'next/navigation'; + import { onrampServices } from '@/bridge/components/BuyPanel/utils'; import { PathnameEnum } from '@/bridge/constants'; import { addOrbitChainsToArbitrumSDK } from '../initialization'; -import { sanitizeAndRedirect } from './sanitizeAndRedirect'; +import { getSanitizedRedirectPath } from './sanitizeAndRedirect'; export type Slug = (typeof onrampServices)[number]['slug']; @@ -11,14 +13,31 @@ export interface BridgePageProps { redirectPath: PathnameEnum | `${PathnameEnum.BUY}/${Slug}` | `${PathnameEnum.EMBED_BUY}/${Slug}`; } -export async function initializeBridgePage({ searchParams, redirectPath }: BridgePageProps) { +export async function getBridgePageSanitizedRedirectPath({ + searchParams, + redirectPath, +}: BridgePageProps) { /** * This code is run on every query param change, * we don't want to sanitize every query param change. * It should only be executed once per user per session. */ - if (searchParams.sanitized !== 'true') { - addOrbitChainsToArbitrumSDK(); - await sanitizeAndRedirect(searchParams, redirectPath); + if (searchParams.sanitized === 'true') { + return null; + } + + addOrbitChainsToArbitrumSDK(); + + return getSanitizedRedirectPath(searchParams, redirectPath); +} + +export async function initializeBridgePage({ searchParams, redirectPath }: BridgePageProps) { + const sanitizedRedirectPath = await getBridgePageSanitizedRedirectPath({ + searchParams, + redirectPath, + }); + + if (sanitizedRedirectPath) { + redirect(sanitizedRedirectPath); } } diff --git a/packages/app/src/utils/sanitizeAndRedirect.ts b/packages/app/src/utils/sanitizeAndRedirect.ts index 46b75e5c4..373b55228 100644 --- a/packages/app/src/utils/sanitizeAndRedirect.ts +++ b/packages/app/src/utils/sanitizeAndRedirect.ts @@ -1,5 +1,7 @@ +import { constants } from 'ethers'; import { redirect } from 'next/navigation'; +import { ChainId } from '@/bridge/types/ChainId'; import { sanitizeExperimentalFeaturesQueryParam } from '@/bridge/util'; import { isE2eTestingEnvironment, isProductionEnvironment } from '@/bridge/util/CommonUtils'; import { logger } from '@/bridge/util/logger'; @@ -99,6 +101,19 @@ export async function sanitizeAndRedirect( [key: string]: string | string[] | undefined; }, baseUrl: string, +) { + const redirectPath = await getSanitizedRedirectPath(searchParams, baseUrl); + + if (redirectPath) { + redirect(redirectPath); + } +} + +export async function getSanitizedRedirectPath( + searchParams: { + [key: string]: string | string[] | undefined; + }, + baseUrl: string, ) { const sourceChainId = decodeChainQueryParam(searchParams.sourceChain); const destinationChainId = decodeChainQueryParam(searchParams.destinationChain); @@ -115,7 +130,7 @@ export async function sanitizeAndRedirect( // If both sourceChain and destinationChain are not present, let the client sync with Metamask if (!sourceChainId && !destinationChainId) { - return; + return null; } if (!isProductionEnvironment || isE2eTestingEnvironment) { @@ -126,19 +141,32 @@ export async function sanitizeAndRedirect( sourceChainId, destinationChainId, }); + const sanitizedToken = sanitizeTokenQueryParam({ + token, + sourceChainId: sanitizedChainIds.sourceChainId, + destinationChainId: sanitizedChainIds.destinationChainId, + }); + let sanitizedDestinationToken = sanitizeTokenQueryParam({ + token: destinationToken, + sourceChainId: sanitizedChainIds.sourceChainId, + destinationChainId: sanitizedChainIds.destinationChainId, + }); + + // Reuse the same default selection behavior as setSelectedToken(null) for ApeChain -> Superposition. + if ( + sanitizedChainIds.sourceChainId === ChainId.ApeChain && + sanitizedChainIds.destinationChainId === ChainId.Superposition && + typeof token === 'undefined' && + typeof destinationToken === 'undefined' + ) { + sanitizedDestinationToken = constants.AddressZero; + } + const sanitized = { ...sanitizedChainIds, experiments: sanitizeExperimentalFeaturesQueryParam(experiments), - token: sanitizeTokenQueryParam({ - token, - sourceChainId: sanitizedChainIds.sourceChainId, - destinationChainId: sanitizedChainIds.destinationChainId, - }), - destinationToken: sanitizeTokenQueryParam({ - token: destinationToken, - sourceChainId: sanitizedChainIds.sourceChainId, - destinationChainId: sanitizedChainIds.destinationChainId, - }), + token: sanitizedToken, + destinationToken: sanitizedDestinationToken, tab: sanitizeTabQueryParam(tab), disabledFeatures: DisabledFeaturesParam.decode(disabledFeatures), }; @@ -161,6 +189,8 @@ export async function sanitizeAndRedirect( `[sanitizeAndRedirect] sourceChain=${sanitized.sourceChainId}&destinationChain=${sanitized.destinationChainId}&experiments=${sanitized.experiments}&token=${sanitized.token}&destinationToken=${sanitized.destinationToken}&tab=${sanitized.tab}&disabledFeatures=${sanitized.disabledFeatures}&sanitized=true (after)`, ); - redirect(getDestinationWithSanitizedQueryParams(sanitized, searchParams, baseUrl)); + return getDestinationWithSanitizedQueryParams(sanitized, searchParams, baseUrl); } + + return null; } diff --git a/packages/app/vitest.config.ts b/packages/app/vitest.config.ts index 033d8c09e..a1def6b9e 100644 --- a/packages/app/vitest.config.ts +++ b/packages/app/vitest.config.ts @@ -10,6 +10,7 @@ export default defineConfig({ }, resolve: { alias: { + '@/common': path.resolve(__dirname, '../portal/common'), '@/bridge': path.resolve(__dirname, '../arb-token-bridge-ui/src'), '@/app-types': path.resolve(__dirname, './src/types'), '@/app-hooks': path.resolve(__dirname, './src/hooks'), diff --git a/packages/arb-token-bridge-ui/package.json b/packages/arb-token-bridge-ui/package.json index b530c8ccd..86464ed9d 100644 --- a/packages/arb-token-bridge-ui/package.json +++ b/packages/arb-token-bridge-ui/package.json @@ -58,6 +58,8 @@ "prebuild": "yarn generateDenylist", "test": "vitest --config vitest.config.ts --watch", "test:ci": "vitest --config vitest.config.ts --run", + "test:integration": "vitest --config vitest.integration.config.ts --run", + "test:integration:watch": "vitest --config vitest.integration.config.ts --watch", "generateDenylist": "ts-node --project ./scripts/tsconfig.json ./scripts/generateDenylist.ts", "generateOpenGraphImages": "ts-node --project ./scripts/tsconfig.json ./src/generateOpenGraphImages.tsx generate", "generateOpenGraphImages:core": "yarn generateOpenGraphImages --core", @@ -94,6 +96,7 @@ "cypress-terminal-report": "^7.1.0", "env-cmd": "^10.1.0", "happy-dom": "^20.8.9", + "jsdom-testing-mocks": "^1.16.0", "postcss": "^8.5.3", "satori": "^0.12.2", "start-server-and-test": "^2.0.11", diff --git a/packages/arb-token-bridge-ui/src/components/TransferPanel/DestinationTokenButton.tsx b/packages/arb-token-bridge-ui/src/components/TransferPanel/DestinationTokenButton.tsx index 2ddcd00fa..e8ca2e9e1 100644 --- a/packages/arb-token-bridge-ui/src/components/TransferPanel/DestinationTokenButton.tsx +++ b/packages/arb-token-bridge-ui/src/components/TransferPanel/DestinationTokenButton.tsx @@ -1,12 +1,12 @@ import { ChevronDownIcon } from '@heroicons/react/24/outline'; import { getTokenOverride, isValidLifiTransfer } from '@/bridge/app/api/crosschain-transfers/utils'; +import { useNetworksRelationship } from '@/bridge/hooks/useNetworksRelationship'; import { useSelectedToken } from '@/bridge/hooks/useSelectedToken'; import { useDestinationToken } from '../../hooks/useDestinationToken'; import { NativeCurrency, useNativeCurrency } from '../../hooks/useNativeCurrency'; import { useNetworks } from '../../hooks/useNetworks'; -import { useNetworksRelationship } from '../../hooks/useNetworksRelationship'; import { sanitizeTokenSymbol } from '../../util/TokenUtils'; import { Button } from '../common/Button'; import { DialogWrapper, useDialog2 } from '../common/Dialog2'; diff --git a/packages/arb-token-bridge-ui/src/components/TransferPanel/TransferPanel.default-tokens.integration.test.tsx b/packages/arb-token-bridge-ui/src/components/TransferPanel/TransferPanel.default-tokens.integration.test.tsx new file mode 100644 index 000000000..a2fa64ccb --- /dev/null +++ b/packages/arb-token-bridge-ui/src/components/TransferPanel/TransferPanel.default-tokens.integration.test.tsx @@ -0,0 +1,319 @@ +import { describe, it } from 'vitest'; + +import { ETHER_TOKEN_LOGO } from '@/bridge/constants'; +import { CommonAddress } from '@/bridge/util/CommonAddressUtils'; + +import { + APE_TOKEN_LOGO, + type RouteTokenCase, + type TokenExpectation, + USDC_TOKEN_LOGO, + WETH_TOKEN_LOGO, + runTransferPanelScenario, + setupTransferPanelLifiIntegrationSuite, +} from './TransferPanel.integration.helpers'; + +const defaultTokenCases: RouteTokenCase[] = [ + { + sourceChain: 'base', + destinationChain: 'apechain', + expectedSourceToken: { symbol: 'APE' }, + expectedDestinationToken: { symbol: 'APE' }, + }, + { + sourceChain: 'base', + destinationChain: 'superposition', + expectedSourceToken: { symbol: 'ETH' }, + expectedDestinationToken: { symbol: 'ETH' }, + }, + { + sourceChain: 'base', + destinationChain: 'arbitrum-one', + expectedSourceToken: { symbol: 'ETH' }, + expectedDestinationToken: { symbol: 'ETH' }, + }, + { + sourceChain: 'arbitrum-one', + destinationChain: 'apechain', + expectedSourceToken: { symbol: 'APE' }, + expectedDestinationToken: { symbol: 'APE' }, + }, + { + sourceChain: 'arbitrum-one', + destinationChain: 'superposition', + expectedSourceToken: { symbol: 'ETH' }, + expectedDestinationToken: { symbol: 'ETH' }, + }, + { + sourceChain: 'apechain', + destinationChain: 'arbitrum-one', + expectedSourceToken: { symbol: 'APE' }, + expectedDestinationToken: { symbol: 'APE' }, + }, + { + sourceChain: 'superposition', + destinationChain: 'arbitrum-one', + expectedSourceToken: { symbol: 'ETH' }, + expectedDestinationToken: { symbol: 'ETH' }, + }, + { + sourceChain: 'ethereum', + destinationChain: 'apechain', + expectedSourceToken: { symbol: 'APE' }, + expectedDestinationToken: { symbol: 'APE' }, + }, + { + sourceChain: 'apechain', + destinationChain: 'ethereum', + expectedSourceToken: { symbol: 'APE' }, + expectedDestinationToken: { symbol: 'APE' }, + }, + { + sourceChain: 'ethereum', + destinationChain: 'superposition', + expectedSourceToken: { symbol: 'ETH' }, + expectedDestinationToken: { symbol: 'ETH' }, + }, + { + sourceChain: 'superposition', + destinationChain: 'ethereum', + expectedSourceToken: { symbol: 'ETH' }, + expectedDestinationToken: { symbol: 'ETH' }, + }, + { + sourceChain: 'apechain', + destinationChain: 'superposition', + expectedSourceToken: { symbol: 'APE' }, + expectedDestinationToken: { symbol: 'ETH' }, + }, + { + sourceChain: 'superposition', + destinationChain: 'apechain', + expectedSourceToken: { symbol: 'ETH' }, + expectedDestinationToken: { symbol: 'WETH' }, + }, +]; + +type DefaultTokenPanelCase = { + sourceChain: RouteTokenCase['sourceChain']; + destinationChain: RouteTokenCase['destinationChain']; + expectedSourcePanelSymbols: TokenExpectation[]; + expectedDestinationPanelSymbols: TokenExpectation[]; +}; + +const defaultTokenPanelCases: DefaultTokenPanelCase[] = [ + { + sourceChain: 'base', + destinationChain: 'apechain', + expectedSourcePanelSymbols: [ + { + symbol: 'APE', + logoURI: APE_TOKEN_LOGO, + }, + ], + expectedDestinationPanelSymbols: [ + { symbol: 'APE', logoURI: APE_TOKEN_LOGO }, + { symbol: 'USDC.e' }, + { symbol: 'USDT' }, + { symbol: 'WETH' }, + ], + }, + { + sourceChain: 'base', + destinationChain: 'superposition', + expectedSourcePanelSymbols: [{ symbol: 'ETH', logoURI: ETHER_TOKEN_LOGO }], + expectedDestinationPanelSymbols: [ + { symbol: 'ETH', logoURI: ETHER_TOKEN_LOGO, contract: 'native' }, + { symbol: 'USDC.e', logoURI: USDC_TOKEN_LOGO, contract: CommonAddress.Superposition.USDCe }, + { + symbol: 'WETH', + logoURI: WETH_TOKEN_LOGO, + contract: '0x1fb719f10b56d7a85dcd32f27f897375fb21cfdd', + }, + ], + }, + { + sourceChain: 'base', + destinationChain: 'arbitrum-one', + expectedSourcePanelSymbols: [{ symbol: 'ETH', logoURI: ETHER_TOKEN_LOGO }], + expectedDestinationPanelSymbols: [ + { symbol: 'ETH', logoURI: ETHER_TOKEN_LOGO, contract: 'native' }, + { symbol: 'USDC', contract: CommonAddress.ArbitrumOne.USDC }, + { symbol: 'USDT', contract: CommonAddress.ArbitrumOne.USDT }, + { symbol: 'WETH', contract: '0x82af49447d8a07e3bd95bd0d56f35241523fbab1' }, + ], + }, + { + sourceChain: 'arbitrum-one', + destinationChain: 'apechain', + expectedSourcePanelSymbols: [ + { + symbol: 'APE', + logoURI: APE_TOKEN_LOGO, + }, + ], + expectedDestinationPanelSymbols: [ + { symbol: 'APE', logoURI: APE_TOKEN_LOGO }, + { symbol: 'USDC.e' }, + { symbol: 'USDT' }, + { symbol: 'WETH' }, + ], + }, + { + sourceChain: 'arbitrum-one', + destinationChain: 'superposition', + expectedSourcePanelSymbols: [{ symbol: 'ETH', logoURI: ETHER_TOKEN_LOGO }], + expectedDestinationPanelSymbols: [ + { symbol: 'ETH', logoURI: ETHER_TOKEN_LOGO, contract: 'native' }, + { symbol: 'USDC.e', contract: CommonAddress.Superposition.USDCe }, + { symbol: 'WETH', contract: '0x1fb719f10b56d7a85dcd32f27f897375fb21cfdd' }, + ], + }, + { + sourceChain: 'apechain', + destinationChain: 'arbitrum-one', + expectedSourcePanelSymbols: [{ symbol: 'APE', logoURI: APE_TOKEN_LOGO }], + expectedDestinationPanelSymbols: [ + { + symbol: 'APE', + logoURI: APE_TOKEN_LOGO, + }, + { symbol: 'USDC', contract: CommonAddress.ArbitrumOne.USDC }, + { symbol: 'USDT', contract: CommonAddress.ArbitrumOne.USDT }, + { symbol: 'WETH', contract: '0x82af49447d8a07e3bd95bd0d56f35241523fbab1' }, + { symbol: 'ETH', contract: 'native' }, + ], + }, + { + sourceChain: 'superposition', + destinationChain: 'arbitrum-one', + expectedSourcePanelSymbols: [{ symbol: 'ETH', logoURI: ETHER_TOKEN_LOGO }], + expectedDestinationPanelSymbols: [ + { symbol: 'ETH', logoURI: ETHER_TOKEN_LOGO, contract: 'native' }, + { symbol: 'USDC', contract: CommonAddress.ArbitrumOne.USDC }, + { symbol: 'WETH', contract: '0x82af49447d8a07e3bd95bd0d56f35241523fbab1' }, + ], + }, + { + sourceChain: 'ethereum', + destinationChain: 'apechain', + expectedSourcePanelSymbols: [ + { + symbol: 'APE', + logoURI: APE_TOKEN_LOGO, + }, + ], + expectedDestinationPanelSymbols: [ + { symbol: 'APE', logoURI: APE_TOKEN_LOGO }, + { symbol: 'USDC.e' }, + { symbol: 'USDT' }, + { symbol: 'WETH' }, + ], + }, + { + sourceChain: 'apechain', + destinationChain: 'ethereum', + expectedSourcePanelSymbols: [{ symbol: 'APE', logoURI: APE_TOKEN_LOGO }], + expectedDestinationPanelSymbols: [ + { + symbol: 'APE', + logoURI: APE_TOKEN_LOGO, + }, + { symbol: 'USDC' }, + { symbol: 'USDT' }, + { symbol: 'WETH' }, + { symbol: 'ETH' }, + ], + }, + { + sourceChain: 'ethereum', + destinationChain: 'superposition', + expectedSourcePanelSymbols: [{ symbol: 'ETH', logoURI: ETHER_TOKEN_LOGO }], + expectedDestinationPanelSymbols: [ + { symbol: 'ETH', logoURI: ETHER_TOKEN_LOGO, contract: 'native' }, + { symbol: 'USDC.e', contract: CommonAddress.Superposition.USDCe }, + { symbol: 'WETH', contract: '0x1fb719f10b56d7a85dcd32f27f897375fb21cfdd' }, + ], + }, + { + sourceChain: 'superposition', + destinationChain: 'ethereum', + expectedSourcePanelSymbols: [{ symbol: 'ETH', logoURI: ETHER_TOKEN_LOGO }], + expectedDestinationPanelSymbols: [ + { symbol: 'ETH', logoURI: ETHER_TOKEN_LOGO, contract: 'native' }, + { symbol: 'USDC', contract: CommonAddress.Ethereum.USDC }, + { symbol: 'WETH', contract: '0xC02aaA39b223FE8D0A0e5C4F27eAD9083C756Cc2' }, + ], + }, + { + sourceChain: 'apechain', + destinationChain: 'superposition', + expectedSourcePanelSymbols: [{ symbol: 'APE', logoURI: APE_TOKEN_LOGO }], + expectedDestinationPanelSymbols: [ + { symbol: 'ETH', logoURI: ETHER_TOKEN_LOGO, contract: 'native' }, + { symbol: 'USDC.e', logoURI: USDC_TOKEN_LOGO, contract: CommonAddress.Superposition.USDCe }, + { + symbol: 'WETH', + logoURI: WETH_TOKEN_LOGO, + contract: '0x1fb719f10b56d7a85dcd32f27f897375fb21cfdd', + }, + ], + }, + { + sourceChain: 'superposition', + destinationChain: 'apechain', + expectedSourcePanelSymbols: [{ symbol: 'ETH', logoURI: ETHER_TOKEN_LOGO }], + expectedDestinationPanelSymbols: [ + { + symbol: 'WETH', + logoURI: WETH_TOKEN_LOGO, + }, + { symbol: 'APE' }, + { symbol: 'USDC.e' }, + ], + }, +]; + +describe.sequential('TransferPanel LiFi Integration - Default Token', () => { + setupTransferPanelLifiIntegrationSuite(); + + it.each(defaultTokenCases)( + 'renders expected source and destination tokens for default token transfer: $sourceChain -> $destinationChain', + async ({ sourceChain, destinationChain, expectedSourceToken, expectedDestinationToken }) => { + await runTransferPanelScenario({ + sourceChain, + destinationChain, + expectedSourceToken, + expectedDestinationToken, + }); + }, + ); + + it.each(defaultTokenPanelCases)( + 'opens source and destination token panels with expected entries for default token transfer: $sourceChain -> $destinationChain', + async ({ + sourceChain, + destinationChain, + expectedSourcePanelSymbols, + expectedDestinationPanelSymbols, + }) => { + const sourcePanelTokenExpectation = expectedSourcePanelSymbols[0]; + const destinationPanelTokenExpectation = expectedDestinationPanelSymbols[0]; + + if (!sourcePanelTokenExpectation || !destinationPanelTokenExpectation) { + throw new Error( + `Missing primary token expectation for "${sourceChain}" -> "${destinationChain}".`, + ); + } + + await runTransferPanelScenario({ + sourceChain, + destinationChain, + expectedSourceToken: sourcePanelTokenExpectation, + expectedDestinationToken: destinationPanelTokenExpectation, + expectedSourcePanelTokens: expectedSourcePanelSymbols, + expectedDestinationPanelTokens: expectedDestinationPanelSymbols, + }); + }, + ); +}); diff --git a/packages/arb-token-bridge-ui/src/components/TransferPanel/TransferPanel.eth-weth.integration.test.tsx b/packages/arb-token-bridge-ui/src/components/TransferPanel/TransferPanel.eth-weth.integration.test.tsx new file mode 100644 index 000000000..452c49781 --- /dev/null +++ b/packages/arb-token-bridge-ui/src/components/TransferPanel/TransferPanel.eth-weth.integration.test.tsx @@ -0,0 +1,64 @@ +import { constants } from 'ethers'; +import { describe, it } from 'vitest'; + +import { + type RouteTokenCase, + runTransferPanelScenario, + setupTransferPanelLifiIntegrationSuite, +} from './TransferPanel.integration.helpers'; + +const ethWethCases: RouteTokenCase[] = [ + { + sourceChain: 'ethereum', + destinationChain: 'apechain', + expectedSourceToken: { symbol: 'APE' }, + expectedDestinationToken: { symbol: 'WETH' }, + }, + { + sourceChain: 'apechain', + destinationChain: 'ethereum', + expectedSourceToken: { symbol: 'APE' }, + expectedDestinationToken: { symbol: 'ETH' }, + }, + { + sourceChain: 'ethereum', + destinationChain: 'superposition', + expectedSourceToken: { symbol: 'ETH' }, + expectedDestinationToken: { symbol: 'ETH' }, + }, + { + sourceChain: 'superposition', + destinationChain: 'ethereum', + expectedSourceToken: { symbol: 'ETH' }, + expectedDestinationToken: { symbol: 'ETH' }, + }, + { + sourceChain: 'apechain', + destinationChain: 'superposition', + expectedSourceToken: { symbol: 'APE' }, + expectedDestinationToken: { symbol: 'ETH' }, + }, + { + sourceChain: 'superposition', + destinationChain: 'apechain', + expectedSourceToken: { symbol: 'ETH' }, + expectedDestinationToken: { symbol: 'WETH' }, + }, +]; + +describe.sequential('TransferPanel LiFi Integration - ETH/WETH Override', () => { + setupTransferPanelLifiIntegrationSuite(); + + it.each(ethWethCases)( + 'renders expected source and destination tokens for ETH/WETH override: $sourceChain -> $destinationChain', + async ({ sourceChain, destinationChain, expectedSourceToken, expectedDestinationToken }) => { + await runTransferPanelScenario({ + sourceChain, + destinationChain, + expectedSourceToken, + expectedDestinationToken, + destinationToken: constants.AddressZero, + }); + }, + ); +}); diff --git a/packages/arb-token-bridge-ui/src/components/TransferPanel/TransferPanel.integration.helpers.tsx b/packages/arb-token-bridge-ui/src/components/TransferPanel/TransferPanel.integration.helpers.tsx new file mode 100644 index 000000000..45522b9d0 --- /dev/null +++ b/packages/arb-token-bridge-ui/src/components/TransferPanel/TransferPanel.integration.helpers.tsx @@ -0,0 +1,585 @@ +import { registerCustomArbitrumNetwork } from '@arbitrum/sdk'; +import { act, fireEvent, render, screen, waitFor, within } from '@testing-library/react'; +import { beforeAll, expect } from 'vitest'; + +import { getBridgePageSanitizedRedirectPath } from '../../../../app/src/utils/bridgePageUtils'; +import { ETHER_TOKEN_LOGO, PathnameEnum } from '../../constants'; +import { ContractStorage, ERC20BridgeToken, TokenType } from '../../hooks/arbTokenBridge.types'; +import { + createIntegrationWrapper, + getSearchParams, +} from '../../test-utils/integration-test-wrapper'; +import { ChainId } from '../../types/ChainId'; +import { CommonAddress } from '../../util/CommonAddressUtils'; +import { mapCustomChainToNetworkData } from '../../util/networks'; +import orbitChainsData from '../../util/orbitChainsData.json'; +import { TransferPanel } from './TransferPanel'; + +export type ChainQuerySlug = 'ethereum' | 'arbitrum-one' | 'base' | 'apechain' | 'superposition'; +const INTEGRATION_ASSERT_TIMEOUT_MS = 2_000; +const POLL_INTERVAL_MS = 50; +const TOKEN_BUTTON_ASSERT_TIMEOUT_MS = 6_000; +const TOKEN_PANEL_CONTENT_ASSERT_TIMEOUT_MS = 8_000; + +export type TokenExpectation = { + symbol: string; + logoURI?: string; + contract?: string | 'native'; +}; + +export const APE_TOKEN_LOGO = '/images/ApeTokenLogo.svg'; +export const WETH_TOKEN_LOGO = + 'https://raw.githubusercontent.com/trustwallet/assets/master/blockchains/ethereum/assets/0xC02aaA39b223FE8D0A0e5C4F27eAD9083C756Cc2/logo.png'; +export const USDC_TOKEN_LOGO = + 'https://raw.githubusercontent.com/trustwallet/assets/master/blockchains/arbitrum/assets/0xaf88d065e77c8cC2239327C5EDb3A432268e5831/logo.png'; + +const tokenLogosBySymbol: Record = { + 'ETH': ETHER_TOKEN_LOGO, + 'APE': APE_TOKEN_LOGO, + 'WETH': WETH_TOKEN_LOGO, + 'USDC': USDC_TOKEN_LOGO, + 'USDC.e': USDC_TOKEN_LOGO, +}; + +export function withExpectedTokenLogo(tokenExpectation: TokenExpectation): TokenExpectation { + if (tokenExpectation.logoURI) { + return tokenExpectation; + } + + const logoURI = tokenLogosBySymbol[tokenExpectation.symbol]; + if (!logoURI) { + return tokenExpectation; + } + + return { + ...tokenExpectation, + logoURI, + }; +} + +export type RouteTokenCase = { + sourceChain: ChainQuerySlug; + destinationChain: ChainQuerySlug; + expectedSourceToken: TokenExpectation; + expectedDestinationToken: TokenExpectation; +}; + +export type TransferPanelScenarioRenderConfig = { + sourceChain: ChainQuerySlug; + destinationChain: ChainQuerySlug; + token?: string; + destinationToken?: string; + bridgeTokens?: ContractStorage; +}; + +export type TransferPanelScenario = TransferPanelScenarioRenderConfig & { + name?: string; + expectedSourceToken: TokenExpectation; + expectedDestinationToken: TokenExpectation; + expectedSourcePanelTokens?: TokenExpectation[]; + expectedDestinationPanelTokens?: TokenExpectation[]; +}; + +export const usdcAddressByChain: Record = { + 'ethereum': CommonAddress.Ethereum.USDC, + 'arbitrum-one': CommonAddress.ArbitrumOne.USDC, + 'base': CommonAddress.Base.USDC, + 'apechain': CommonAddress.ApeChain.USDCe, + 'superposition': CommonAddress.Superposition.USDCe, +}; + +function escapeRegExp(value: string): string { + return value.replace(/[.*+?^${}()|[\]\\]/g, '\\$&'); +} + +function sleep(ms: number) { + return new Promise((resolve) => setTimeout(resolve, ms)); +} + +async function sleepInAct(ms: number) { + await act(async () => { + await sleep(ms); + }); +} + +async function getSearchParamsAfterSanitization({ + sourceChain, + destinationChain, + token, + destinationToken, +}: { + sourceChain: ChainQuerySlug; + destinationChain: ChainQuerySlug; + token?: string; + destinationToken?: string; +}) { + const searchParams = { + sourceChain, + destinationChain, + token, + destinationToken, + }; + + const sanitizedRedirectPath = await getBridgePageSanitizedRedirectPath({ + searchParams, + redirectPath: PathnameEnum.BRIDGE, + }); + + if (sanitizedRedirectPath) { + return new URL(sanitizedRedirectPath, 'http://localhost:3000').search; + } + + return getSearchParams(searchParams); +} + +export function getUsdcSourceToken(sourceChain: ChainQuerySlug): ERC20BridgeToken { + const usesNativeUsdc = + sourceChain === 'ethereum' || sourceChain === 'arbitrum-one' || sourceChain === 'base'; + + return { + type: TokenType.ERC20, + decimals: 6, + name: usesNativeUsdc ? 'USD Coin' : 'Bridged USDC', + symbol: usesNativeUsdc ? 'USDC' : 'USDC.e', + address: usdcAddressByChain[sourceChain], + listIds: new Set(), + }; +} + +function getAutoSeededBridgeTokens({ + sourceChain, + token, +}: { + sourceChain: ChainQuerySlug; + token?: string; +}): ContractStorage | undefined { + if (!token) { + return undefined; + } + + if (token.toLowerCase() !== usdcAddressByChain[sourceChain].toLowerCase()) { + return undefined; + } + + return { + [token.toLowerCase()]: getUsdcSourceToken(sourceChain), + }; +} + +export async function renderTransferPanel({ + sourceChain, + destinationChain, + token, + destinationToken, + bridgeTokens, +}: { + sourceChain: ChainQuerySlug; + destinationChain: ChainQuerySlug; + token?: string; + destinationToken?: string; + bridgeTokens?: ContractStorage; +}) { + const search = await getSearchParamsAfterSanitization({ + sourceChain, + destinationChain, + token, + destinationToken, + }); + + const resolvedBridgeTokens = bridgeTokens ?? getAutoSeededBridgeTokens({ sourceChain, token }); + + const wrapper = createIntegrationWrapper({ + search, + bridgeTokens: resolvedBridgeTokens, + }); + + await act(async () => { + render(, { wrapper }); + }); + + // Let mount-time effects settle before assertions start. + await sleepInAct(0); +} + +export async function expectTokenButtonToken({ + isDestination, + tokenExpectation, +}: { + isDestination: boolean; + tokenExpectation: TokenExpectation; +}) { + const buttonAriaLabel = isDestination ? 'Select Destination Token' : 'Select Token'; + const symbolRegex = new RegExp(`\\b${escapeRegExp(tokenExpectation.symbol)}\\b`, 'i'); + + type TokenButtonSnapshot = { + found: boolean; + buttonText: string; + symbolText: string; + logoSrc: string | null; + }; + + const getTokenButtonSnapshot = () => { + try { + const latestTokenButton = screen.getByRole('button', { + name: buttonAriaLabel, + hidden: true, + }); + const logoImage = latestTokenButton.querySelector('img'); + const symbolElement = latestTokenButton.querySelector('span.font-light'); + const symbolText = symbolElement?.textContent?.trim() ?? ''; + + return { + found: true, + buttonText: latestTokenButton.textContent?.trim() ?? '', + symbolText, + logoSrc: logoImage?.getAttribute('src') ?? null, + } satisfies TokenButtonSnapshot; + } catch { + return { + found: false, + buttonText: '', + symbolText: '', + logoSrc: null, + } satisfies TokenButtonSnapshot; + } + }; + + const formatTokenButtonSnapshot = (snapshot: TokenButtonSnapshot) => { + if (!snapshot.found) { + return 'button not found yet'; + } + + return `symbol=${JSON.stringify(snapshot.symbolText)} rawText=${JSON.stringify(snapshot.buttonText)}`; + }; + + await waitFor( + () => { + const snapshot = getTokenButtonSnapshot(); + if (!snapshot.found || !snapshot.symbolText) { + throw new Error( + `Waiting for "${buttonAriaLabel}" symbol to resolve. Current state: ${formatTokenButtonSnapshot(snapshot)}.`, + ); + } + + if (!symbolRegex.test(snapshot.symbolText)) { + throw new Error( + `Expected "${buttonAriaLabel}" button text to contain "${tokenExpectation.symbol}", got symbol "${snapshot.symbolText}" (raw: "${snapshot.buttonText}").`, + ); + } + }, + { + timeout: TOKEN_BUTTON_ASSERT_TIMEOUT_MS, + interval: POLL_INTERVAL_MS, + onTimeout: () => { + const snapshot = getTokenButtonSnapshot(); + return new Error( + `Timed out waiting for "${buttonAriaLabel}" button text to contain "${tokenExpectation.symbol}". Current state: ${formatTokenButtonSnapshot(snapshot)}.`, + ); + }, + }, + ); + + const logoURI = tokenExpectation.logoURI; + if (logoURI) { + await waitFor( + () => { + const snapshot = getTokenButtonSnapshot(); + if (!snapshot.found || !snapshot.logoSrc) { + throw new Error( + `Waiting for "${buttonAriaLabel}" logo to resolve. Current state: ${formatTokenButtonSnapshot(snapshot)}.`, + ); + } + + if (!snapshot.logoSrc.includes(logoURI)) { + throw new Error( + `Expected "${buttonAriaLabel}" logo to contain "${logoURI}", got "${snapshot.logoSrc}".`, + ); + } + }, + { + timeout: TOKEN_BUTTON_ASSERT_TIMEOUT_MS, + interval: POLL_INTERVAL_MS, + onTimeout: () => { + const snapshot = getTokenButtonSnapshot(); + return new Error( + `Timed out waiting for "${buttonAriaLabel}" button logo to contain "${logoURI}". Current state: ${formatTokenButtonSnapshot(snapshot)}, current src: ${JSON.stringify(snapshot.logoSrc)}.`, + ); + }, + }, + ); + } +} + +function getTokenPanelRowButtonBySymbol(dialog: HTMLElement, symbol: string): HTMLButtonElement { + const symbolMatcher = new RegExp(`^${escapeRegExp(symbol)}$`, 'i'); + const symbolElements = within(dialog).queryAllByText(symbolMatcher); + + const rowButton = symbolElements + .map((element) => element.closest('button')) + .find( + (button): button is HTMLButtonElement => + button !== null && button.tagName.toLowerCase() === 'button', + ); + + if (!rowButton) { + throw new Error(`Unable to find token row for symbol "${symbol}".`); + } + + return rowButton; +} + +function getTokenPanelRowButtons(dialog: HTMLElement): HTMLButtonElement[] { + return within(dialog) + .queryAllByRole('button') + .filter((button): button is HTMLButtonElement => { + if (!(button instanceof HTMLButtonElement)) { + return false; + } + + if (button.getAttribute('aria-label') === 'Close Dialog') { + return false; + } + + const normalizedText = button.textContent?.replace(/\s+/g, ' ').trim().toLowerCase() ?? ''; + if (!normalizedText) { + return false; + } + + if (normalizedText === 'add') { + return false; + } + + if (normalizedText.includes('manage token lists')) { + return false; + } + + return true; + }); +} + +export async function expectTokenPanelContent({ + isDestination, + symbolsToContain, + tokenExpectation, +}: { + isDestination: boolean; + symbolsToContain?: string[]; + tokenExpectation?: TokenExpectation; +}) { + const buttonAriaLabel = isDestination ? 'Select Destination Token' : 'Select Token'; + const dialogTitle = isDestination ? 'Select Destination Token' : 'Select Token'; + + const openTokenPanelButton = await screen.findByRole('button', { + name: buttonAriaLabel, + hidden: true, + }); + await act(async () => { + fireEvent.click(openTokenPanelButton); + }); + + const dialog = await screen.findByRole('dialog'); + await within(dialog).findByText(dialogTitle); + + if (symbolsToContain) { + const getAvailableButtons = () => + getTokenPanelRowButtons(dialog) + .map((button) => button.textContent?.replace(/\s+/g, ' ').trim() ?? '') + .filter(Boolean) + .slice(0, 20); + + const getMissingSymbols = () => + symbolsToContain.filter((symbol) => { + try { + getTokenPanelRowButtonBySymbol(dialog, symbol); + return false; + } catch { + return true; + } + }); + + await waitFor( + () => { + const missingSymbols = getMissingSymbols(); + if (missingSymbols.length > 0) { + throw new Error( + `Missing symbol(s) "${missingSymbols.join(', ')}" in ${isDestination ? 'destination' : 'source'} token panel.`, + ); + } + }, + { + timeout: TOKEN_PANEL_CONTENT_ASSERT_TIMEOUT_MS, + interval: POLL_INTERVAL_MS, + onTimeout: () => { + const missingSymbols = getMissingSymbols(); + return new Error( + `Timed out waiting for [${missingSymbols.join(', ')}] in ${isDestination ? 'destination' : 'source'} token panel. Available rows: [${getAvailableButtons().join(' | ')}].`, + ); + }, + }, + ); + } + + if (tokenExpectation) { + if (typeof tokenExpectation.logoURI !== 'undefined') { + await waitFor( + () => { + const tokenRowButton = getTokenPanelRowButtonBySymbol(dialog, tokenExpectation.symbol); + const logoImage = tokenRowButton.querySelector('img'); + const rowLogoSrc = logoImage?.getAttribute('src') ?? ''; + + if (!rowLogoSrc) { + throw new Error(`Waiting for "${tokenExpectation.symbol}" row logo to resolve.`); + } + + if (tokenExpectation.logoURI && !rowLogoSrc.includes(tokenExpectation.logoURI)) { + throw new Error( + `Expected "${tokenExpectation.symbol}" row logo to contain "${tokenExpectation.logoURI}", got "${rowLogoSrc}".`, + ); + } + }, + { + timeout: INTEGRATION_ASSERT_TIMEOUT_MS, + interval: POLL_INTERVAL_MS, + onTimeout: () => + new Error( + `Timed out waiting for "${tokenExpectation.symbol}" row logo to contain "${tokenExpectation.logoURI}".`, + ), + }, + ); + } + + if (typeof tokenExpectation.contract !== 'undefined') { + const expectsNativeContract = tokenExpectation.contract === 'native'; + const expectedContractAddress = expectsNativeContract + ? undefined + : tokenExpectation.contract.toLowerCase(); + + await waitFor( + () => { + const tokenRowButton = getTokenPanelRowButtonBySymbol(dialog, tokenExpectation.symbol); + const links = Array.from(tokenRowButton.querySelectorAll('a[href]')); + const rowText = (tokenRowButton.textContent ?? '').toLowerCase(); + const hasAnyTokenContractLink = links.some((link) => { + const href = (link.getAttribute('href') ?? '').toLowerCase(); + return href.includes('/token/'); + }); + const hasExpectedContractLink = links.some((link) => { + const href = (link.getAttribute('href') ?? '').toLowerCase(); + return expectedContractAddress + ? href.includes(`/token/${expectedContractAddress}`) + : false; + }); + + if (expectsNativeContract) { + expect(rowText).toContain('native token on'); + expect(hasAnyTokenContractLink).toBe(false); + return; + } + + expect(hasExpectedContractLink).toBe(true); + }, + { + timeout: INTEGRATION_ASSERT_TIMEOUT_MS, + onTimeout: () => + new Error( + expectsNativeContract + ? `Timed out waiting for "${tokenExpectation.symbol}" row to show native token text without token contract links.` + : `Timed out waiting for "${tokenExpectation.symbol}" row contract link to include "${tokenExpectation.contract}".`, + ), + }, + ); + } + } + + const closeDialogButton = within(dialog).getByLabelText('Close Dialog'); + await act(async () => { + fireEvent.click(closeDialogButton); + }); + + await waitFor( + () => { + expect(screen.queryByRole('dialog')).toBeNull(); + }, + { + timeout: INTEGRATION_ASSERT_TIMEOUT_MS, + onTimeout: () => new Error('Timed out waiting for token selection dialog to close.'), + }, + ); +} + +async function expectTokenPanelTokens({ + isDestination, + tokenExpectations, + scenarioName, +}: { + isDestination: boolean; + tokenExpectations: TokenExpectation[]; + scenarioName: string; +}) { + const primaryTokenExpectation = tokenExpectations[0]; + if (!primaryTokenExpectation) { + throw new Error( + `Missing primary ${isDestination ? 'destination' : 'source'} panel token expectation for scenario "${scenarioName}".`, + ); + } + + await expectTokenPanelContent({ + isDestination, + symbolsToContain: tokenExpectations.map(({ symbol }) => symbol), + tokenExpectation: withExpectedTokenLogo(primaryTokenExpectation), + }); +} + +export async function runTransferPanelScenario({ + name, + expectedSourceToken, + expectedDestinationToken, + expectedSourcePanelTokens, + expectedDestinationPanelTokens, + ...renderConfig +}: TransferPanelScenario) { + const scenarioName = name ?? `${renderConfig.sourceChain} -> ${renderConfig.destinationChain}`; + + await renderTransferPanel(renderConfig); + + await expectTokenButtonToken({ + isDestination: false, + tokenExpectation: withExpectedTokenLogo(expectedSourceToken), + }); + await expectTokenButtonToken({ + isDestination: true, + tokenExpectation: withExpectedTokenLogo(expectedDestinationToken), + }); + + if (expectedSourcePanelTokens) { + await expectTokenPanelTokens({ + isDestination: false, + tokenExpectations: expectedSourcePanelTokens, + scenarioName, + }); + } + + if (expectedDestinationPanelTokens) { + await expectTokenPanelTokens({ + isDestination: true, + tokenExpectations: expectedDestinationPanelTokens, + scenarioName, + }); + } +} + +export function setupTransferPanelLifiIntegrationSuite() { + beforeAll(() => { + process.env.NEXT_PUBLIC_FEATURE_FLAG_LIFI = 'true'; + + const apeChain = orbitChainsData.mainnet.find((chain) => chain.chainId === ChainId.ApeChain)!; + const superposition = orbitChainsData.mainnet.find( + (chain) => chain.chainId === ChainId.Superposition, + )!; + + registerCustomArbitrumNetwork(apeChain); + registerCustomArbitrumNetwork(superposition); + mapCustomChainToNetworkData(apeChain); + mapCustomChainToNetworkData(superposition); + }); +} diff --git a/packages/arb-token-bridge-ui/src/components/TransferPanel/TransferPanel.swap.integration.test.tsx b/packages/arb-token-bridge-ui/src/components/TransferPanel/TransferPanel.swap.integration.test.tsx new file mode 100644 index 000000000..76ef746d8 --- /dev/null +++ b/packages/arb-token-bridge-ui/src/components/TransferPanel/TransferPanel.swap.integration.test.tsx @@ -0,0 +1,67 @@ +import { constants } from 'ethers'; +import { describe, it } from 'vitest'; + +import { + type RouteTokenCase, + runTransferPanelScenario, + setupTransferPanelLifiIntegrationSuite, + usdcAddressByChain, +} from './TransferPanel.integration.helpers'; + +const swapCases: RouteTokenCase[] = [ + { + sourceChain: 'ethereum', + destinationChain: 'apechain', + expectedSourceToken: { symbol: 'USDC' }, + expectedDestinationToken: { symbol: 'WETH' }, + }, + { + sourceChain: 'apechain', + destinationChain: 'ethereum', + expectedSourceToken: { symbol: 'USDC.e' }, + expectedDestinationToken: { symbol: 'ETH' }, + }, + { + sourceChain: 'ethereum', + destinationChain: 'superposition', + expectedSourceToken: { symbol: 'USDC' }, + expectedDestinationToken: { symbol: 'ETH' }, + }, + { + sourceChain: 'superposition', + destinationChain: 'ethereum', + expectedSourceToken: { symbol: 'USDC.e' }, + expectedDestinationToken: { symbol: 'ETH' }, + }, + { + sourceChain: 'apechain', + destinationChain: 'superposition', + expectedSourceToken: { symbol: 'USDC.e' }, + expectedDestinationToken: { symbol: 'ETH' }, + }, + { + sourceChain: 'superposition', + destinationChain: 'apechain', + expectedSourceToken: { symbol: 'USDC.e' }, + expectedDestinationToken: { symbol: 'WETH' }, + }, +]; + +describe.sequential('TransferPanel LiFi Integration - Swap (USDC -> ETH/WETH)', () => { + setupTransferPanelLifiIntegrationSuite(); + + it.each(swapCases)( + 'renders expected source and destination tokens for swap (USDC -> ETH/WETH): $sourceChain -> $destinationChain', + async ({ sourceChain, destinationChain, expectedSourceToken, expectedDestinationToken }) => { + const sourceTokenAddress = usdcAddressByChain[sourceChain]; + await runTransferPanelScenario({ + sourceChain, + destinationChain, + expectedSourceToken, + expectedDestinationToken, + token: sourceTokenAddress, + destinationToken: constants.AddressZero, + }); + }, + ); +}); diff --git a/packages/arb-token-bridge-ui/src/components/TransferPanel/TransferPanel.usdc.integration.test.tsx b/packages/arb-token-bridge-ui/src/components/TransferPanel/TransferPanel.usdc.integration.test.tsx new file mode 100644 index 000000000..da5436137 --- /dev/null +++ b/packages/arb-token-bridge-ui/src/components/TransferPanel/TransferPanel.usdc.integration.test.tsx @@ -0,0 +1,67 @@ +import { describe, it } from 'vitest'; + +import { + type RouteTokenCase, + runTransferPanelScenario, + setupTransferPanelLifiIntegrationSuite, + usdcAddressByChain, +} from './TransferPanel.integration.helpers'; + +const usdcCases: RouteTokenCase[] = [ + { + sourceChain: 'ethereum', + destinationChain: 'apechain', + expectedSourceToken: { symbol: 'USDC' }, + expectedDestinationToken: { symbol: 'USDC.e' }, + }, + { + sourceChain: 'apechain', + destinationChain: 'ethereum', + expectedSourceToken: { symbol: 'USDC.e' }, + expectedDestinationToken: { symbol: 'USDC' }, + }, + { + sourceChain: 'ethereum', + destinationChain: 'superposition', + expectedSourceToken: { symbol: 'USDC' }, + expectedDestinationToken: { symbol: 'USDC.e' }, + }, + { + sourceChain: 'superposition', + destinationChain: 'ethereum', + expectedSourceToken: { symbol: 'USDC.e' }, + expectedDestinationToken: { symbol: 'USDC' }, + }, + { + sourceChain: 'apechain', + destinationChain: 'superposition', + expectedSourceToken: { symbol: 'USDC.e' }, + expectedDestinationToken: { symbol: 'USDC.e' }, + }, + { + sourceChain: 'superposition', + destinationChain: 'apechain', + expectedSourceToken: { symbol: 'USDC.e' }, + expectedDestinationToken: { symbol: 'USDC.e' }, + }, +]; + +describe.sequential('TransferPanel LiFi Integration - USDC', () => { + setupTransferPanelLifiIntegrationSuite(); + + it.each(usdcCases)( + 'renders expected source and destination tokens for USDC transfer: $sourceChain -> $destinationChain', + async ({ sourceChain, destinationChain, expectedSourceToken, expectedDestinationToken }) => { + const sourceTokenAddress = usdcAddressByChain[sourceChain]; + await runTransferPanelScenario({ + sourceChain, + destinationChain, + expectedSourceToken, + expectedDestinationToken, + token: sourceTokenAddress, + // `destinationToken` is sanitized from the source token address into the destination-chain token. + destinationToken: sourceTokenAddress, + }); + }, + ); +}); diff --git a/packages/arb-token-bridge-ui/src/components/common/NetworkSelectionContainer.tsx b/packages/arb-token-bridge-ui/src/components/common/NetworkSelectionContainer.tsx index 51ea17974..dd00580f1 100644 --- a/packages/arb-token-bridge-ui/src/components/common/NetworkSelectionContainer.tsx +++ b/packages/arb-token-bridge-ui/src/components/common/NetworkSelectionContainer.tsx @@ -204,10 +204,10 @@ function NetworkRow({
{network.name} -

+

{!walletAddress && ( -

{nativeTokenData?.symbol ?? 'ETH'}

+ {nativeTokenData?.symbol ?? 'ETH'}
)} @@ -232,7 +232,7 @@ function NetworkRow({ )} -

+
); diff --git a/packages/arb-token-bridge-ui/src/hooks/TransferPanel/useGasEstimates.ts b/packages/arb-token-bridge-ui/src/hooks/TransferPanel/useGasEstimates.ts index ad3ba9612..659a763b4 100644 --- a/packages/arb-token-bridge-ui/src/hooks/TransferPanel/useGasEstimates.ts +++ b/packages/arb-token-bridge-ui/src/hooks/TransferPanel/useGasEstimates.ts @@ -7,8 +7,12 @@ import { shallow } from 'zustand/shallow'; import { TransferEstimateGasResult } from '@/token-bridge-sdk/BridgeTransferStarter'; import { BridgeTransferStarterFactory } from '@/token-bridge-sdk/BridgeTransferStarterFactory'; +import { CctpTransferStarter } from '@/token-bridge-sdk/CctpTransferStarter'; +import { LifiTransferStarter } from '@/token-bridge-sdk/LifiTransferStarter'; +import { OftV2TransferStarter } from '@/token-bridge-sdk/OftV2TransferStarter'; import { getProviderForChainId } from '@/token-bridge-sdk/utils'; +import { LifiCrosschainTransfersRoute, Order } from '../../app/api/crosschain-transfers/lifi'; import { getTokenOverride } from '../../app/api/crosschain-transfers/utils'; import { useLifiSettingsStore } from '../../components/TransferPanel/hooks/useLifiSettingsStore'; import { @@ -29,7 +33,40 @@ import { useNetworks } from '../useNetworks'; import { useNetworksRelationship } from '../useNetworksRelationship'; import { useSelectedToken } from '../useSelectedToken'; +function getLifiRouteByType({ + routeType, + lifiRoutes, +}: { + routeType: RouteType; + lifiRoutes: LifiCrosschainTransfersRoute[] | undefined; +}) { + if (!lifiRoutes?.length || !isLifiRoute(routeType)) { + return undefined; + } + + if (routeType === 'lifi') { + return ( + lifiRoutes.find( + (route) => + route.protocolData.orders.includes(Order.Cheapest) && + route.protocolData.orders.includes(Order.Fastest), + ) ?? lifiRoutes[0] + ); + } + + if (routeType === 'lifi-cheapest') { + return lifiRoutes.find((route) => route.protocolData.orders.includes(Order.Cheapest)); + } + + if (routeType === 'lifi-fastest') { + return lifiRoutes.find((route) => route.protocolData.orders.includes(Order.Fastest)); + } + + return undefined; +} + async function fetcher([ + routeType, walletAddress, sourceChainId, destinationChainId, @@ -40,6 +77,7 @@ async function fetcher([ wagmiConfig, routeContext, ]: [ + routeType: RouteType, walletAddress: string | undefined, sourceChainId: number, destinationChainId: number, @@ -52,15 +90,45 @@ async function fetcher([ ]): Promise { const _walletAddress = walletAddress ?? constants.AddressZero; const sourceProvider = getProviderForChainId(sourceChainId); + const destinationProvider = getProviderForChainId(destinationChainId); const signer = sourceProvider.getSigner(_walletAddress); - // use chainIds to initialize the bridgeTransferStarter to save RPC calls - const bridgeTransferStarter = BridgeTransferStarterFactory.create({ - sourceChainId, - sourceChainErc20Address, - destinationChainId, - destinationChainErc20Address, - lifiData: routeContext, - }); + let bridgeTransferStarter; + + if (isLifiRoute(routeType)) { + if (!routeContext) { + return undefined; + } + + bridgeTransferStarter = new LifiTransferStarter({ + sourceChainProvider: sourceProvider, + sourceChainErc20Address, + destinationChainProvider: destinationProvider, + destinationChainErc20Address, + lifiData: routeContext, + }); + } else if (routeType === 'cctp') { + bridgeTransferStarter = new CctpTransferStarter({ + sourceChainProvider: sourceProvider, + sourceChainErc20Address, + destinationChainProvider: destinationProvider, + destinationChainErc20Address, + }); + } else if (routeType === 'oftV2') { + bridgeTransferStarter = new OftV2TransferStarter({ + sourceChainProvider: sourceProvider, + sourceChainErc20Address, + destinationChainProvider: destinationProvider, + destinationChainErc20Address, + }); + } else { + // canonical / teleport + bridgeTransferStarter = BridgeTransferStarterFactory.create({ + sourceChainId, + sourceChainErc20Address, + destinationChainId, + destinationChainErc20Address, + }); + } return await bridgeTransferStarter.transferEstimateGas({ amount, @@ -92,17 +160,25 @@ export function useGasEstimates({ const { address: walletAddress } = useAccount(); const balance = useBalanceOnSourceChain(selectedToken); const wagmiConfig = useConfig(); - const { context, eligibleRouteTypes } = useRouteStore( + const { eligibleRouteTypes, selectedRoute, context } = useRouteStore( (state) => ({ - context: state.context, eligibleRouteTypes: state.eligibleRouteTypes, + selectedRoute: state.selectedRoute, + context: state.context, }), shallow, ); - const allRoutesAreLifi = useMemo( - () => eligibleRouteTypes.every((route: RouteType) => isLifiRoute(route)), - [eligibleRouteTypes], - ); + const routeTypeForGasEstimate = useMemo(() => { + if ( + selectedRoute && + (eligibleRouteTypes.includes(selectedRoute) || + (isLifiRoute(selectedRoute) && eligibleRouteTypes.includes('lifi'))) + ) { + return selectedRoute; + } + + return eligibleRouteTypes[0]; + }, [eligibleRouteTypes, selectedRoute]); const isLifiRouteEligible = eligibleRouteTypes.includes('lifi'); const overrideSourceToken = useMemo( @@ -167,21 +243,39 @@ export function useGasEstimates({ const { data: gasEstimates, error } = useSWR( () => { - if (allRoutesAreLifi && (isLoadingLifiRoutes || lifiRoutes?.length === 0)) { + if (!routeTypeForGasEstimate) { return null; } - /** - * If route is selected, pass context from that route - * If no route are selected and it's a lifi only route (for example Base to Arbitrum One), - * pass the first lifi route as context - * Otherwise, default to canonical transfer - */ - const lifiContext = allRoutesAreLifi - ? lifiRoutes?.[0] && getContextFromRoute(lifiRoutes?.[0]) - : context; + const isRouteLifi = isLifiRoute(routeTypeForGasEstimate); + + let routeContext: RouteContext | undefined = undefined; + if (isRouteLifi) { + if (selectedRoute === routeTypeForGasEstimate && context) { + routeContext = context; + } else { + if (isLoadingLifiRoutes || !lifiRoutes?.length) { + return null; + } + + const selectedLifiRoute = getLifiRouteByType({ + routeType: routeTypeForGasEstimate, + lifiRoutes, + }); + if (!selectedLifiRoute) { + return null; + } + + routeContext = getContextFromRoute(selectedLifiRoute); + } + } + + if (isRouteLifi && !routeContext) { + return null; + } return [ + routeTypeForGasEstimate, sourceChain.id, destinationChain.id, sourceChainErc20Address, @@ -190,11 +284,12 @@ export function useGasEstimates({ sanitizedDestinationAddress, walletAddress, wagmiConfig, - lifiContext, + routeContext, 'gasEstimates', ] as const; }, ([ + _routeType, _sourceChainId, _destinationChainId, _sourceChainErc20Address, @@ -206,6 +301,7 @@ export function useGasEstimates({ _routeContext, ]) => fetcher([ + _routeType, _walletAddress, _sourceChainId, _destinationChainId, diff --git a/packages/arb-token-bridge-ui/src/test-utils/integration-test-wrapper.tsx b/packages/arb-token-bridge-ui/src/test-utils/integration-test-wrapper.tsx new file mode 100644 index 000000000..8df7f1228 --- /dev/null +++ b/packages/arb-token-bridge-ui/src/test-utils/integration-test-wrapper.tsx @@ -0,0 +1,104 @@ +import { QueryClient, QueryClientProvider } from '@tanstack/react-query'; +import { createOvermindMock } from 'overmind'; +import { Provider as OvermindProvider } from 'overmind-react'; +import React, { PropsWithChildren, useMemo, useState } from 'react'; +import { SWRConfig } from 'swr'; +import { + PartialLocation, + QueryParamAdapter, + QueryParamAdapterComponent, + QueryParamProvider, +} from 'use-query-params'; +import { WagmiProvider, createConfig, http } from 'wagmi'; +import { mainnet } from 'wagmi/chains'; + +import { ContractStorage, ERC20BridgeToken } from '../hooks/arbTokenBridge.types'; +import { queryParamProviderOptions } from '../hooks/useArbQueryParams'; +import { config } from '../state'; + +function createAdapter(initialLocation: PartialLocation): QueryParamAdapterComponent { + return ({ children }) => { + const [currentLocation, setCurrentLocation] = useState(initialLocation); + + const adapter = useMemo( + () => ({ + replace: (newLocation) => setCurrentLocation((prev) => ({ ...prev, ...newLocation })), + push: (newLocation) => setCurrentLocation((prev) => ({ ...prev, ...newLocation })), + get location() { + return currentLocation; + }, + }), + [currentLocation], + ); + + return children(adapter); + }; +} + +type CreateIntegrationWrapperParams = { + search?: string; + bridgeTokens?: ContractStorage; +}; + +export function createIntegrationWrapper({ + search = '', + bridgeTokens, +}: CreateIntegrationWrapperParams = {}) { + const queryClient = new QueryClient(); + const adapter = createAdapter({ + search, + }); + const wagmiConfig = createConfig({ + chains: [mainnet], + transports: { + [mainnet.id]: http(), + }, + multiInjectedProviderDiscovery: false, + ssr: false, + }); + + const overmind = createOvermindMock(config, (state) => { + if (!bridgeTokens) { + return; + } + + state.app.arbTokenBridge = { + bridgeTokens, + eth: {} as never, + token: {} as never, + }; + }); + + const Wrapper = ({ children }: PropsWithChildren) => ( + + + + + new Map(), + }} + > + {children} + + + + + + ); + + return Wrapper; +} + +export function getSearchParams(params: Record): string { + const searchParams = new URLSearchParams(); + + Object.entries(params).forEach(([key, value]) => { + if (typeof value !== 'undefined') { + searchParams.set(key, String(value)); + } + }); + + const serialized = searchParams.toString(); + return serialized ? `?${serialized}` : ''; +} diff --git a/packages/arb-token-bridge-ui/vitest.config.ts b/packages/arb-token-bridge-ui/vitest.config.ts index eb3686689..916edb50a 100644 --- a/packages/arb-token-bridge-ui/vitest.config.ts +++ b/packages/arb-token-bridge-ui/vitest.config.ts @@ -3,6 +3,9 @@ import { loadEnv } from 'vite'; import { defineConfig } from 'vitest/config'; export default defineConfig({ + esbuild: { + jsx: 'automatic', + }, test: { globals: true, sequence: { concurrent: true }, @@ -11,6 +14,7 @@ export default defineConfig({ }, testTimeout: 15_000, include: ['./src/**/*.test.ts', './src/**/*.test.tsx'], + exclude: ['./src/**/*.integration.test.ts', './src/**/*.integration.test.tsx'], env: loadEnv('', '../app/', ''), environment: 'happy-dom', setupFiles: ['./vitest.mocks.ts'], @@ -20,6 +24,7 @@ export default defineConfig({ '@/images': path.resolve(__dirname, '../app/public/images'), '@/icons': path.resolve(__dirname, '../app/public/icons'), '@/common': path.resolve(__dirname, '../portal/common'), + '@/components': path.resolve(__dirname, '../portal/components'), '@/portal': path.resolve(__dirname, '../portal'), '@/app-components': path.resolve(__dirname, '../app/src/components'), '@/token-bridge-sdk': path.resolve(__dirname, './src/token-bridge-sdk'), diff --git a/packages/arb-token-bridge-ui/vitest.integration.config.ts b/packages/arb-token-bridge-ui/vitest.integration.config.ts new file mode 100644 index 000000000..3c1855771 --- /dev/null +++ b/packages/arb-token-bridge-ui/vitest.integration.config.ts @@ -0,0 +1,39 @@ +import path from 'path'; +import { loadEnv } from 'vite'; +import { defineConfig } from 'vitest/config'; + +export default defineConfig({ + esbuild: { + jsx: 'automatic', + }, + test: { + globals: true, + fileParallelism: true, + sequence: { concurrent: true }, + snapshotFormat: { + escapeString: true, + }, + testTimeout: 120_000, + include: ['./src/**/*.integration.test.ts', './src/**/*.integration.test.tsx'], + env: loadEnv('', '../app/', ''), + environment: 'happy-dom', + environmentOptions: { + happyDOM: { + url: 'http://localhost:3000', + }, + }, + setupFiles: ['./vitest.mocks.ts', './vitest.integration.setup.tsx'], + }, + resolve: { + alias: { + '@/images': path.resolve(__dirname, '../app/public/images'), + '@/icons': path.resolve(__dirname, '../app/public/icons'), + '@/common': path.resolve(__dirname, '../portal/common'), + '@/components': path.resolve(__dirname, '../portal/components'), + '@/portal': path.resolve(__dirname, '../portal'), + '@/app-components': path.resolve(__dirname, '../app/src/components'), + '@/token-bridge-sdk': path.resolve(__dirname, './src/token-bridge-sdk'), + '@/bridge': path.resolve(__dirname, './src'), + }, + }, +}); diff --git a/packages/arb-token-bridge-ui/vitest.integration.setup.tsx b/packages/arb-token-bridge-ui/vitest.integration.setup.tsx new file mode 100644 index 000000000..ef93bcac0 --- /dev/null +++ b/packages/arb-token-bridge-ui/vitest.integration.setup.tsx @@ -0,0 +1,96 @@ +import axios from 'axios'; +import { mockAnimationsApi } from 'jsdom-testing-mocks'; +import React from 'react'; +import { vi } from 'vitest'; + +import { ChainId } from './src/types/ChainId'; +import { rpcURLs } from './src/util/networks'; + +vi.mock('@arbitrum/sdk/dist/lib/abi/factories/Inbox__factory', async () => { + const { BigNumber } = await import('ethers'); + + return { + Inbox__factory: { + connect: vi.fn(() => ({ + calculateRetryableSubmissionFee: vi.fn().mockResolvedValue(BigNumber.from(0)), + })), + }, + }; +}); + +vi.mock('next/navigation', async (importActual) => ({ + ...(await importActual()), + usePathname: vi.fn().mockReturnValue('/bridge'), +})); + +vi.mock('react-virtualized', () => ({ + AutoSizer: ({ + children, + }: { + children: (props: { width: number; height: number }) => React.ReactElement; + }) => children({ width: 500, height: 700 }), + List: React.forwardRef(function MockList( + { + rowCount, + rowRenderer, + }: { + rowCount: number; + rowRenderer: (props: { + index: number; + key: number; + style: React.CSSProperties; + }) => React.ReactElement | null; + }, + _ref: React.ForwardedRef, + ) { + void _ref; + return ( +
+ {Array.from({ length: rowCount }).map((_, index) => ( +
{rowRenderer({ index, key: index, style: {} })}
+ ))} +
+ ); + }), +})); + +class MockLoadedImage { + onload: ((event: Event) => void) | null = null; + onerror: ((event: Event) => void) | null = null; + + set src(_value: string) { + queueMicrotask(() => { + this.onload?.(new Event('load')); + }); + } +} + +// SafeImage waits for browser image loading before rendering an ; make this deterministic in tests. +vi.stubGlobal('Image', MockLoadedImage); + +mockAnimationsApi(); + +const localhostPattern = /(?:^|\/\/)(?:localhost|127\.0\.0\.1)(?::\d+)?(?:\/|$)/i; +const publicEthereumRpcUrl = 'https://ethereum-rpc.publicnode.com'; +const publicArbitrumRpcUrl = 'https://arb1.arbitrum.io/rpc'; + +function ensurePublicRpcEnv(key: string, publicRpcUrl: string) { + const currentValue = process.env[key]; + if (!currentValue || localhostPattern.test(currentValue)) { + process.env[key] = publicRpcUrl; + } +} + +// Integration tests should not depend on a local node process. +ensurePublicRpcEnv('NEXT_PUBLIC_RPC_URL_ETHEREUM', publicEthereumRpcUrl); +ensurePublicRpcEnv('NEXT_PUBLIC_RPC_URL_ARBITRUM_ONE', publicArbitrumRpcUrl); +ensurePublicRpcEnv('NEXT_PUBLIC_RPC_URL_BASE', 'https://mainnet.base.org'); + +// Keep integration tests off local nitro testnode RPCs even if local chain ids are resolved internally. +rpcURLs[ChainId.Local] = publicEthereumRpcUrl; +rpcURLs[ChainId.ArbitrumLocal] = publicArbitrumRpcUrl; +rpcURLs[ChainId.L3Local] = publicArbitrumRpcUrl; + +axios.defaults.baseURL = 'http://localhost:3000'; +// Avoid happy-dom's XMLHttpRequest adapter to prevent async task manager abort rejections. +(axios.defaults as { adapter?: string }).adapter = 'http'; diff --git a/yarn.lock b/yarn.lock index b4c052bf9..892facc9e 100644 --- a/yarn.lock +++ b/yarn.lock @@ -5466,10 +5466,10 @@ bare-events@^2.5.4, bare-events@^2.7.0: resolved "https://registry.yarnpkg.com/bare-events/-/bare-events-2.8.2.tgz#7b3e10bd8e1fc80daf38bb516921678f566ab89f" integrity sha512-riJjyv1/mHLIPX4RwiK+oW9/4c3TEUeORHKefKAKnZ5kyslbN+HXowtbaVEqt4IMUB7OXlfixcs6gsFeo/jhiQ== -bare-fs@^4.0.1, bare-fs@^4.5.5: - version "4.7.0" - resolved "https://registry.yarnpkg.com/bare-fs/-/bare-fs-4.7.0.tgz#01efd92b14a6c93825e8992ac1e42fdd6237de71" - integrity sha512-xzqKsCFxAek9aezYhjJuJRXBIaYlg/0OGDTZp+T8eYmYMlm66cs6cYko02drIyjN2CBbi+I6L7YfXyqpqtKRXA== +bare-fs@^4.0.1: + version "4.5.5" + resolved "https://registry.yarnpkg.com/bare-fs/-/bare-fs-4.5.5.tgz#589a8f87a32af0266aa474413c8d7d11d50e4a65" + integrity sha512-XvwYM6VZqKoqDll8BmSww5luA5eflDzY0uEFfBJtFKe4PAAtxBjU3YIxzIBzhyaEQBy1VXEQBto4cpN5RZJw+w== dependencies: bare-events "^2.5.4" bare-path "^3.0.0" @@ -5478,9 +5478,9 @@ bare-fs@^4.0.1, bare-fs@^4.5.5: fast-fifo "^1.3.2" bare-os@^3.0.1: - version "3.8.7" - resolved "https://registry.yarnpkg.com/bare-os/-/bare-os-3.8.7.tgz#09c7c4e8c817de750b0b69b65c929513f69ede65" - integrity sha512-G4Gr1UsGeEy2qtDTZwL7JFLo2wapUarz7iTMcYcMFdS89AIQuBoyjgXZz0Utv7uHs3xA9LckhVbeBi8lEQrC+w== + version "3.7.0" + resolved "https://registry.yarnpkg.com/bare-os/-/bare-os-3.7.0.tgz#23c60064e53400db1550ef4b2987fdc42ee399b2" + integrity sha512-64Rcwj8qlnTZU8Ps6JJEdSmxBEUGgI7g8l+lMtsJLl4IsfTcHMTfJ188u2iGV6P6YPRZrtv72B2kjn+hp+Yv3g== bare-path@^3.0.0: version "3.0.0" @@ -5566,6 +5566,11 @@ betsy@1.0.2: "@types/node" "^10.5.1" tslib "^1.9.3" +bezier-easing@^2.1.0: + version "2.1.0" + resolved "https://registry.yarnpkg.com/bezier-easing/-/bezier-easing-2.1.0.tgz#c04dfe8b926d6ecaca1813d69ff179b7c2025d86" + integrity sha512-gbIqZ/eslnUFC1tjEvtz0sgx+xTK20wDnYMIA27VA04R7w6xxXQPZDbibjA9DTWZRA2CXtwHykkVzlCaAJAZig== + big.js@^5.2.2: version "5.2.2" resolved "https://registry.yarnpkg.com/big.js/-/big.js-5.2.2.tgz#65f0af382f578bcdc742bd9c281e9cb2d7768328" @@ -6480,6 +6485,11 @@ css-in-js-utils@^3.1.0: dependencies: hyphenate-style-name "^1.0.3" +css-mediaquery@^0.1.2: + version "0.1.2" + resolved "https://registry.yarnpkg.com/css-mediaquery/-/css-mediaquery-0.1.2.tgz#6a2c37344928618631c54bd33cedd301da18bea0" + integrity sha512-COtn4EROW5dBGlE/4PiKnh6rZpAPxDeFLaEEwt4i10jpDMFt2EhQGS79QmmrO+iKCHv0PU/HrOWEhijFd1x99Q== + css-select@^4.1.3: version "4.3.0" resolved "https://registry.yarnpkg.com/css-select/-/css-select-4.3.0.tgz#db7129b2846662fd8628cfc496abb2b59e41529b" @@ -10066,6 +10076,14 @@ jsbn@~0.1.0: resolved "https://registry.yarnpkg.com/jsbn/-/jsbn-0.1.1.tgz#a5e654c2e5a2deb5f201d96cefbca80c0ef2f513" integrity sha512-UVU9dibq2JcFWxQPA6KCqj5O42VOmAY3zQUfEKxU0KpTGXwNoCjkX1e13eHNvw/xPynt6pU0rZ1htjWTNTSXsg== +jsdom-testing-mocks@^1.16.0: + version "1.16.0" + resolved "https://registry.yarnpkg.com/jsdom-testing-mocks/-/jsdom-testing-mocks-1.16.0.tgz#2ec5962c9a4e5e89ecfd7f29560e37f44c66ea2a" + integrity sha512-wLrulXiLpjmcUYOYGEvz4XARkrmdVpyxzdBl9IAMbQ+ib2/UhUTRCn49McdNfXLff2ysGBUms49ZKX0LR1Q0gg== + dependencies: + bezier-easing "^2.1.0" + css-mediaquery "^0.1.2" + jsesc@^3.0.2: version "3.1.0" resolved "https://registry.yarnpkg.com/jsesc/-/jsesc-3.1.0.tgz#74d335a234f67ed19907fdadfac7ccf9d409825d" @@ -13629,12 +13647,11 @@ tar-stream@^2.1.4: readable-stream "^3.1.1" tar-stream@^3.1.5: - version "3.1.8" - resolved "https://registry.yarnpkg.com/tar-stream/-/tar-stream-3.1.8.tgz#a26f5b26c34dfd4936a4f8a9e694a8f5102af13d" - integrity sha512-U6QpVRyCGHva435KoNWy9PRoi2IFYCgtEhq9nmrPPpbRacPs9IH4aJ3gbrFC8dPcXvdSZ4XXfXT5Fshbp2MtlQ== + version "3.1.7" + resolved "https://registry.yarnpkg.com/tar-stream/-/tar-stream-3.1.7.tgz#24b3fb5eabada19fe7338ed6d26e5f7c482e792b" + integrity sha512-qJj60CXt7IU1Ffyc3NJMjh6EkuCFej46zUqJ4J7pqYlThyd9bO0XBTmcOIhSzZJVWfsLks0+nle/j538YAW9RQ== dependencies: b4a "^1.6.4" - bare-fs "^4.5.5" fast-fifo "^1.2.0" streamx "^2.15.0"