From 6fac1a99b7b1080a9473d52276bb66d19df4a329 Mon Sep 17 00:00:00 2001 From: salimtb Date: Mon, 17 Aug 2026 17:39:32 +0200 Subject: [PATCH 1/6] feat(asset): add sticky Buy/Swap CTA bar on token detail page Pin Swap and Buy to the bottom of TDP V2 so they stay reachable while scrolling, matching the mobile sticky footer layout and safe-area handling. --- .../app/wallet-overview/coin-buttons.tsx | 4 +- ui/components/multichain/toast/index.scss | 2 +- ui/pages/asset/asset.scss | 32 +++ ui/pages/asset/components/asset-page.tsx | 9 + .../components/asset-sticky-actions.test.tsx | 105 ++++++++++ .../asset/components/asset-sticky-actions.tsx | 197 ++++++++++++++++++ 6 files changed, 347 insertions(+), 2 deletions(-) create mode 100644 ui/pages/asset/components/asset-sticky-actions.test.tsx create mode 100644 ui/pages/asset/components/asset-sticky-actions.tsx diff --git a/ui/components/app/wallet-overview/coin-buttons.tsx b/ui/components/app/wallet-overview/coin-buttons.tsx index 029af5d1a0c5..53cac5d0d94b 100644 --- a/ui/components/app/wallet-overview/coin-buttons.tsx +++ b/ui/components/app/wallet-overview/coin-buttons.tsx @@ -96,7 +96,9 @@ const NATIVE_SWAP_TOKEN_OVERRIDE_PER_CHAIN: { [key: string]: BridgeAsset } = { [ARC_HEX_CHAIN_ID]: ARC_ERC20_USDC_BRIDGE_ASSET, }; -function getSwapNativeTokenWithOverridesForChain(chainId: string): BridgeAsset { +export function getSwapNativeTokenWithOverridesForChain( + chainId: string, +): BridgeAsset { const override = NATIVE_SWAP_TOKEN_OVERRIDE_PER_CHAIN[chainId]; return override ?? getNativeAssetForChainId(chainId); } diff --git a/ui/components/multichain/toast/index.scss b/ui/components/multichain/toast/index.scss index 7106d6874206..234e6a719d60 100644 --- a/ui/components/multichain/toast/index.scss +++ b/ui/components/multichain/toast/index.scss @@ -47,7 +47,7 @@ } /* Lift the toaster above fixed CTA footers */ -:root:has(.cta-footer, .multichain-page-footer, .dapp-connection-control-bar, .bottom-nav-bar) { +:root:has(.cta-footer, .multichain-page-footer, .dapp-connection-control-bar, .bottom-nav-bar, .asset-page__sticky-actions) { --toaster-bottom-offset: 80px; } diff --git a/ui/pages/asset/asset.scss b/ui/pages/asset/asset.scss index 1f06a45541c2..f89aafdee527 100644 --- a/ui/pages/asset/asset.scss +++ b/ui/pages/asset/asset.scss @@ -3,9 +3,20 @@ .asset { &__container { background-color: var(--color-background-default); + // `.main-container` owns `overflow-y: auto`, but the app shell's flex chain + // lets it keep its full content height, so it never actually scrolls — the + // scrolling happens further up on `.app`. That silently breaks + // `position: sticky` for the bottom CTA bar, whose nearest scrollport is + // then this non-scrolling box. Letting it shrink makes it the real + // scrollport so the CTA bar can pin to the bottom of the viewport. + min-height: 0; } } +.main-container-wrapper:has(> .asset__container) { + min-height: 0; +} + .asset-navigation { display: flex; align-items: center; @@ -107,3 +118,24 @@ height: 100%; border-radius: 1rem; } + +// Persistent bottom CTA bar (Buy / Swap) for the Token Detail Page V2. Mirrors +// the Mobile sticky footer: pinned to the bottom of the scroll area, elevated +// above the content, and padded for the device safe-area inset. +.asset-page__sticky-actions { + position: sticky; + inset-inline: 0; + bottom: 0; + // Direct flex child of the scrolling `.main-container`: never let it be + // compressed, and keep it at the bottom even when the page is short enough + // that it does not scroll. + flex-shrink: 0; + margin-top: auto; + z-index: 1; + background-color: var(--color-background-default); + border-top: 1px solid var(--color-border-muted); + box-shadow: 0 -4px 12px var(--color-shadow-default); + padding: 12px 16px; + // Safe-area handling for devices with a home indicator / rounded corners. + padding-bottom: calc(16px + env(safe-area-inset-bottom, 0px)); +} diff --git a/ui/pages/asset/components/asset-page.tsx b/ui/pages/asset/components/asset-page.tsx index 1d582a85a352..e4f09d5db231 100644 --- a/ui/pages/asset/components/asset-page.tsx +++ b/ui/pages/asset/components/asset-page.tsx @@ -106,6 +106,7 @@ import { isMusdToken } from '../../../components/app/musd/constants'; import { processAssetParams } from '../util'; import { AssetInactiveBadge } from '../../../components/app/assets/asset-inactive-badge/asset-inactive-badge'; import { AssetMarketDetails } from './asset-market-details'; +import { AssetStickyActions } from './asset-sticky-actions'; import AssetChart from './chart/asset-chart'; import { MarketClosedActionButton } from './market-closed-action-button'; import TokenButtons from './token-buttons'; @@ -729,6 +730,14 @@ const AssetPage = ({ onClose={() => setIsMarketClosedModalOpen(false)} /> + {/* Sibling of `asset__content` so it is a direct child of the scrolling + container, which is what lets it stick to the bottom of the viewport. */} + ); }; diff --git a/ui/pages/asset/components/asset-sticky-actions.test.tsx b/ui/pages/asset/components/asset-sticky-actions.test.tsx new file mode 100644 index 000000000000..25c0021b58f4 --- /dev/null +++ b/ui/pages/asset/components/asset-sticky-actions.test.tsx @@ -0,0 +1,105 @@ +import React from 'react'; +import configureMockStore from 'redux-mock-store'; +import { fireEvent, waitFor } from '@testing-library/react'; +import { renderWithProvider } from '../../../../test/lib/render-helpers-navigate'; +import { CHAIN_IDS } from '../../../../shared/constants/network'; +import { mockNetworkState } from '../../../../test/stub/networks'; +import { AssetType } from '../../../../shared/constants/transaction'; +import { toAssetId } from '../../../../shared/lib/asset-utils'; +import { MetaMetricsSwapsEventSource } from '../../../../shared/constants/metametrics'; +import { Asset } from '../types/asset'; +import { AssetStickyActions } from './asset-sticky-actions'; + +const mockGoToBuy = jest.fn().mockResolvedValue(true); +jest.mock('../../../hooks/ramps/useRampsNavigation/useRampsNavigation', () => ({ + // eslint-disable-next-line @typescript-eslint/naming-convention + __esModule: true, + default: () => ({ + goToBuy: mockGoToBuy, + opensBuyInPortfolioTab: false, + }), +})); + +const mockOpenBridgeExperience = jest.fn(); +jest.mock('../../../hooks/bridge/useBridging', () => ({ + // eslint-disable-next-line @typescript-eslint/naming-convention + __esModule: true, + default: () => ({ openBridgeExperience: mockOpenBridgeExperience }), +})); + +const mockTrackEvent = jest.fn(); +jest.mock('../../../hooks/useAnalytics', () => { + const { createEventBuilder } = jest.requireActual( + '../../../../shared/lib/analytics/create-event-builder', + ); + return { + useAnalytics: () => ({ trackEvent: mockTrackEvent, createEventBuilder }), + }; +}); + +const token = { + type: AssetType.token, + address: '0x6b175474e89094c44da98b954eedeac495271d0f', + chainId: CHAIN_IDS.MAINNET, + decimals: 18, + symbol: 'DAI', + image: '', +} as Asset & { type: AssetType.token }; + +const store = configureMockStore()({ + metamask: { + ...mockNetworkState({ chainId: CHAIN_IDS.MAINNET }), + useExternalServices: true, + }, +}); + +describe('AssetStickyActions', () => { + beforeEach(() => jest.clearAllMocks()); + + it('routes the Buy button through goToBuy with the token as intent assetId', () => { + const { getByTestId } = renderWithProvider( + , + store, + ); + + fireEvent.click(getByTestId('asset-sticky-buy')); + expect(mockGoToBuy).toHaveBeenCalledWith({ + assetId: toAssetId(token.address, token.chainId), + chainId: token.chainId, + }); + }); + + it('does not track a buy click when the ramps gate blocks the buy', async () => { + mockGoToBuy.mockResolvedValueOnce(false); + const { getByTestId } = renderWithProvider( + , + store, + ); + + fireEvent.click(getByTestId('asset-sticky-buy')); + await waitFor(() => expect(mockGoToBuy).toHaveBeenCalled()); + expect(mockTrackEvent).not.toHaveBeenCalled(); + }); + + it('opens the swap experience with the token as the source asset', () => { + const { getByTestId } = renderWithProvider( + , + store, + ); + + fireEvent.click(getByTestId('asset-sticky-swap')); + expect(mockOpenBridgeExperience).toHaveBeenCalledWith( + MetaMetricsSwapsEventSource.TokenView, + token, + ); + }); + + it('disables swap while the stock market is closed', () => { + const { getByTestId } = renderWithProvider( + , + store, + ); + + expect(getByTestId('asset-sticky-swap')).toBeDisabled(); + }); +}); diff --git a/ui/pages/asset/components/asset-sticky-actions.tsx b/ui/pages/asset/components/asset-sticky-actions.tsx new file mode 100644 index 000000000000..0024b5f722b8 --- /dev/null +++ b/ui/pages/asset/components/asset-sticky-actions.tsx @@ -0,0 +1,197 @@ +import { + Box, + BoxFlexDirection, + Button, + ButtonSize, + ButtonVariant, + IconName, +} from '@metamask/design-system-react'; +import type { CaipAssetType } from '@metamask/utils'; +import React, { useCallback, useContext } from 'react'; +import { useSelector } from 'react-redux'; +import { ALL_ALLOWED_BRIDGE_CHAIN_IDS } from '../../../../shared/constants/bridge'; +import { + MetaMetricsEventCategory, + MetaMetricsEventName, + MetaMetricsSwapsEventSource, +} from '../../../../shared/constants/metametrics'; +import { toAssetId } from '../../../../shared/lib/asset-utils'; +import { getSwapNativeTokenWithOverridesForChain } from '../../../components/app/wallet-overview/coin-buttons'; +import { transitionForward } from '../../../components/ui/transition'; +import { I18nContext } from '../../../contexts/i18n'; +import { showBuyTabOpenedToast } from '../../../helpers/utils/show-buy-tab-opened-toast'; +import useBridging from '../../../hooks/bridge/useBridging'; +import useRampsNavigation from '../../../hooks/ramps/useRampsNavigation/useRampsNavigation'; +import { useAnalytics } from '../../../hooks/useAnalytics'; +import { getUseExternalServices } from '../../../selectors'; +import { isNativeAsset, type Asset } from '../types/asset'; +import { + useAssetPageSecurityTrustCtaGate, + useAssetPageSecurityTrustCtaGateReady, +} from './security-trust'; + +type AssetStickyActionsProps = { + asset: Asset; + /** + * CAIP-19 asset to pre-select when buying a native asset. Tokens derive it + * from their contract address instead. + */ + buyAssetId?: CaipAssetType; + /** Disables Swap while a stock token's market is closed */ + isMarketClosed?: boolean; + /** Native assets cannot swap from accounts that cannot sign */ + isSigningEnabled?: boolean; +}; + +/** + * Swap and Buy calls to action, pinned to the bottom of the asset page so they + * stay reachable while the rest of the page scrolls. + * + * @param props - The component props. + * @param props.asset - The native or token asset being displayed. + * @param props.buyAssetId - CAIP-19 asset to pre-select when buying a native asset. + * @param props.isMarketClosed - Whether the asset's stock market is closed. + * @param props.isSigningEnabled - Whether the selected account can sign. + */ +export const AssetStickyActions = ({ + asset, + buyAssetId, + isMarketClosed = false, + isSigningEnabled = true, +}: AssetStickyActionsProps) => { + const t = useContext(I18nContext); + const { trackEvent, createEventBuilder } = useAnalytics(); + const isExternalServicesEnabled = useSelector(getUseExternalServices); + const gateCtaAction = useAssetPageSecurityTrustCtaGate(); + const isCtaGateReady = useAssetPageSecurityTrustCtaGateReady(); + const { goToBuy, opensBuyInPortfolioTab } = useRampsNavigation(); + const { openBridgeExperience } = useBridging(); + + const isNative = isNativeAsset(asset); + const { chainId, symbol } = asset; + + const handleBuyClick = useCallback(async () => { + const runBuy = async () => { + const assetId = isNativeAsset(asset) + ? buyAssetId + : toAssetId(asset.address, chainId); + + const opened = await goToBuy({ assetId, chainId }); + // The ramps gate can block the buy and show its own modal; don't report a + // buy click in that case. + if (!opened) { + return; + } + + // Only the Portfolio paths open a browser tab; when goToBuy navigates + // in-app the "tab opened" toast would be misleading. + if (opensBuyInPortfolioTab) { + showBuyTabOpenedToast( + t('buyTabOpenedToastText'), + t('buyTabOpenedToastDescription'), + ); + } + + trackEvent( + createEventBuilder(MetaMetricsEventName.NavBuyButtonClicked) + .addCategory(MetaMetricsEventCategory.Navigation) + .addProperties({ + location: 'Token Overview', + text: 'Buy', + // TODO: Fix in https://github.com/MetaMask/metamask-extension/issues/31860 + // eslint-disable-next-line @typescript-eslint/naming-convention + chain_id: chainId, + // TODO: Fix in https://github.com/MetaMask/metamask-extension/issues/31860 + // eslint-disable-next-line @typescript-eslint/naming-convention + token_symbol: symbol, + }) + .build(), + ); + }; + + if (gateCtaAction) { + gateCtaAction(runBuy, 'buy'); + return; + } + + await runBuy(); + }, [ + asset, + buyAssetId, + chainId, + createEventBuilder, + gateCtaAction, + goToBuy, + opensBuyInPortfolioTab, + symbol, + t, + trackEvent, + ]); + + const handleSwapClick = useCallback(() => { + const runSwap = () => { + if (isNativeAsset(asset)) { + // Native swaps start from the chain's default bridge asset, which some + // chains override (e.g. Arc swaps the ERC20 flavor of USDC). + transitionForward(() => + openBridgeExperience( + MetaMetricsSwapsEventSource.MainView, + ALL_ALLOWED_BRIDGE_CHAIN_IDS.includes(chainId) + ? getSwapNativeTokenWithOverridesForChain(chainId) + : undefined, + ), + ); + return; + } + + openBridgeExperience(MetaMetricsSwapsEventSource.TokenView, asset); + }; + + if (gateCtaAction) { + gateCtaAction(runSwap, 'swap'); + return; + } + + runSwap(); + }, [asset, chainId, gateCtaAction, openBridgeExperience]); + + const isSwapDisabled = + !isExternalServicesEnabled || + !isCtaGateReady || + isMarketClosed || + (isNative && !isSigningEnabled); + + return ( + + + + + ); +}; + +export default AssetStickyActions; From a6b9dd533f75d0e47bef176fa1d83430cac25f0e Mon Sep 17 00:00:00 2001 From: salimtb Date: Mon, 17 Aug 2026 17:39:39 +0200 Subject: [PATCH 2/6] test(e2e): cover sticky Buy/Swap CTA bar on token detail page Assert the TDP sticky footer stays pinned to the viewport bottom across scroll interactions. --- .../pages/asset/asset-sticky-actions.ts | 56 +++++++++++++++ .../tests/tokens/asset-sticky-actions.spec.ts | 69 +++++++++++++++++++ 2 files changed, 125 insertions(+) create mode 100644 test/e2e/page-objects/pages/asset/asset-sticky-actions.ts create mode 100644 test/e2e/tests/tokens/asset-sticky-actions.spec.ts diff --git a/test/e2e/page-objects/pages/asset/asset-sticky-actions.ts b/test/e2e/page-objects/pages/asset/asset-sticky-actions.ts new file mode 100644 index 000000000000..521e15741e4c --- /dev/null +++ b/test/e2e/page-objects/pages/asset/asset-sticky-actions.ts @@ -0,0 +1,56 @@ +import { Driver } from '../../../webdriver/driver'; + +/** + * Sticky Buy / Swap CTA bar on the Token Detail Page V2. + * + * Screen: `#/asset/:chainId/:asset?/:id?` + * Owns: presence of the sticky footer and its Buy / Swap buttons, plus + * asserting the bar stays pinned to the bottom of the viewport after scroll. + * Boundaries: does not own destinations of Buy / Swap clicks. + * + * @see ui/pages/asset/components/asset-sticky-actions.tsx + */ +class AssetStickyActions { + private driver: Driver; + + private readonly stickyActions = '[data-testid="asset-sticky-actions"]'; + + private readonly stickyBuy = '[data-testid="asset-sticky-buy"]'; + + private readonly stickySwap = '[data-testid="asset-sticky-swap"]'; + + constructor(driver: Driver) { + this.driver = driver; + } + + /** + * Waits for the sticky Buy and Swap CTAs to be present. + */ + async checkPageIsLoaded(): Promise { + console.log('Check asset sticky actions are loaded'); + await this.driver.waitForSelector(this.stickyActions); + await this.driver.waitForSelector(this.stickyBuy); + await this.driver.waitForSelector(this.stickySwap); + } + + /** + * Asserts the sticky bar is pinned near the bottom of the visual viewport. + * Uses a small tolerance so scrollbar / safe-area padding do not flake. + */ + async checkPinnedToViewportBottom(): Promise { + console.log('Check asset sticky actions are pinned to the viewport bottom'); + await this.driver.wait(async () => { + const isPinned = await this.driver.executeScript(` + const bar = document.querySelector('[data-testid="asset-sticky-actions"]'); + if (!bar) { + return false; + } + const rect = bar.getBoundingClientRect(); + return Math.abs(rect.bottom - window.innerHeight) < 8 && rect.top >= 0; + `); + return Boolean(isPinned); + }, this.driver.timeout); + } +} + +export default AssetStickyActions; diff --git a/test/e2e/tests/tokens/asset-sticky-actions.spec.ts b/test/e2e/tests/tokens/asset-sticky-actions.spec.ts new file mode 100644 index 000000000000..8407e0a2d193 --- /dev/null +++ b/test/e2e/tests/tokens/asset-sticky-actions.spec.ts @@ -0,0 +1,69 @@ +import { Mockttp } from 'mockttp'; +import { Context } from 'mocha'; +import { CHAIN_IDS } from '../../../../shared/constants/network'; +import FixtureBuilderV2 from '../../fixtures/fixture-builder-v2'; +import { NETWORK_CLIENT_ID } from '../../constants'; +import { withFixtures } from '../../helpers'; +import { Driver } from '../../webdriver/driver'; +import HomePage from '../../page-objects/pages/home/homepage'; +import TokensTab from '../../page-objects/pages/home/tokens-tab'; +import AssetStickyActions from '../../page-objects/pages/asset/asset-sticky-actions'; +import { login } from '../../page-objects/flows/login.flow'; +import { + mockHistoricalPricesV3, + mockSpotPrices, +} from './utils/mocks'; + +describe('Asset sticky actions', function () { + const chainId = CHAIN_IDS.MAINNET; + + it('keeps Buy and Swap pinned to the bottom while the token detail page scrolls', async function () { + await withFixtures( + { + fixtures: new FixtureBuilderV2() + .withSelectedNetwork(NETWORK_CLIENT_ID.MAINNET) + .withEnabledNetworks({ eip155: { [chainId]: true } }) + .build(), + title: (this as Context).test?.fullTitle(), + ethConversionInUsd: 1700, + localNodeOptions: { + chainId: parseInt(chainId, 16), + }, + testSpecificMock: async (mockServer: Mockttp) => [ + await mockSpotPrices(mockServer, { + 'eip155:1/slip44:60': { + price: 1700, + marketCap: 382623505141, + pricePercentChange1d: 0, + }, + }), + await mockHistoricalPricesV3(mockServer, 'eip155:1', 'slip44:60'), + ], + }, + async ({ driver }: { driver: Driver }) => { + await login(driver); + + const homePage = new HomePage(driver); + await homePage.checkPageIsLoaded(); + + const tokensTab = new TokensTab(driver); + await tokensTab.openTokenDetails('Ethereum'); + + const stickyActions = new AssetStickyActions(driver); + await stickyActions.checkPageIsLoaded(); + await stickyActions.checkPinnedToViewportBottom(); + + // Scroll far enough that a non-sticky footer would leave the viewport. + await driver.executeScript(` + const scroller = document.querySelector('.main-container.asset__container'); + if (scroller) { + scroller.scrollTo(0, scroller.scrollHeight); + } + `); + + await stickyActions.checkPageIsLoaded(); + await stickyActions.checkPinnedToViewportBottom(); + }, + ); + }); +}); From 840b0a687824e766c929ecd479129b02d6777fef Mon Sep 17 00:00:00 2001 From: salimtb Date: Mon, 17 Aug 2026 18:01:06 +0200 Subject: [PATCH 3/6] test(e2e): ignore unrelated subscription startup error Keep the sticky CTA assertion focused on page behavior despite the known SubscriptionsController startup race. --- test/e2e/tests/tokens/asset-sticky-actions.spec.ts | 2 ++ 1 file changed, 2 insertions(+) diff --git a/test/e2e/tests/tokens/asset-sticky-actions.spec.ts b/test/e2e/tests/tokens/asset-sticky-actions.spec.ts index 8407e0a2d193..299bb5f5db0e 100644 --- a/test/e2e/tests/tokens/asset-sticky-actions.spec.ts +++ b/test/e2e/tests/tokens/asset-sticky-actions.spec.ts @@ -26,6 +26,8 @@ describe('Asset sticky actions', function () { .build(), title: (this as Context).test?.fullTitle(), ethConversionInUsd: 1700, + // Known SubscriptionsController startup race; unrelated to this page. + ignoredConsoleErrors: ['getSubscriptions'], localNodeOptions: { chainId: parseInt(chainId, 16), }, From f00f9a5410fd9de22bcc88493174bc75d299259f Mon Sep 17 00:00:00 2001 From: salimtb Date: Tue, 18 Aug 2026 10:23:30 +0200 Subject: [PATCH 4/6] fix: fix linter --- test/e2e/tests/tokens/asset-sticky-actions.spec.ts | 5 +---- 1 file changed, 1 insertion(+), 4 deletions(-) diff --git a/test/e2e/tests/tokens/asset-sticky-actions.spec.ts b/test/e2e/tests/tokens/asset-sticky-actions.spec.ts index 299bb5f5db0e..1b7dfaf574b6 100644 --- a/test/e2e/tests/tokens/asset-sticky-actions.spec.ts +++ b/test/e2e/tests/tokens/asset-sticky-actions.spec.ts @@ -9,10 +9,7 @@ import HomePage from '../../page-objects/pages/home/homepage'; import TokensTab from '../../page-objects/pages/home/tokens-tab'; import AssetStickyActions from '../../page-objects/pages/asset/asset-sticky-actions'; import { login } from '../../page-objects/flows/login.flow'; -import { - mockHistoricalPricesV3, - mockSpotPrices, -} from './utils/mocks'; +import { mockHistoricalPricesV3, mockSpotPrices } from './utils/mocks'; describe('Asset sticky actions', function () { const chainId = CHAIN_IDS.MAINNET; From acdd8713c6e954ad724ca969308514b93a57e911 Mon Sep 17 00:00:00 2001 From: salimtb Date: Tue, 18 Aug 2026 16:54:35 +0200 Subject: [PATCH 5/6] test(e2e): move TDP scroll into the sticky-actions page object Address review feedback: the scroll interaction lived in the spec and targeted a brittle CSS class. Add a `data-testid` to the TDP scroll container and a `scrollToBottom()` method on the AssetStickyActions page object so the spec drives UI actions through the POM and a stable testid. Co-authored-by: Cursor --- .../pages/asset/asset-sticky-actions.ts | 18 ++++++++++++++++++ .../tests/tokens/asset-sticky-actions.spec.ts | 8 +------- ui/pages/asset/asset.tsx | 5 ++++- .../security-trust/security-trust-page.tsx | 5 ++++- 4 files changed, 27 insertions(+), 9 deletions(-) diff --git a/test/e2e/page-objects/pages/asset/asset-sticky-actions.ts b/test/e2e/page-objects/pages/asset/asset-sticky-actions.ts index 521e15741e4c..bfb89d4f3a30 100644 --- a/test/e2e/page-objects/pages/asset/asset-sticky-actions.ts +++ b/test/e2e/page-objects/pages/asset/asset-sticky-actions.ts @@ -13,6 +13,9 @@ import { Driver } from '../../../webdriver/driver'; class AssetStickyActions { private driver: Driver; + private readonly scrollContainer = + '[data-testid="asset-page-scroll-container"]'; + private readonly stickyActions = '[data-testid="asset-sticky-actions"]'; private readonly stickyBuy = '[data-testid="asset-sticky-buy"]'; @@ -51,6 +54,21 @@ class AssetStickyActions { return Boolean(isPinned); }, this.driver.timeout); } + + /** + * Scrolls the Token Detail Page scrollport to the bottom, far enough that a + * non-sticky footer would leave the viewport. + */ + async scrollToBottom(): Promise { + console.log('Scroll the token detail page to the bottom'); + await this.driver.waitForSelector(this.scrollContainer); + await this.driver.executeScript(` + const scroller = document.querySelector('[data-testid="asset-page-scroll-container"]'); + if (scroller) { + scroller.scrollTo(0, scroller.scrollHeight); + } + `); + } } export default AssetStickyActions; diff --git a/test/e2e/tests/tokens/asset-sticky-actions.spec.ts b/test/e2e/tests/tokens/asset-sticky-actions.spec.ts index 1b7dfaf574b6..8660ac275028 100644 --- a/test/e2e/tests/tokens/asset-sticky-actions.spec.ts +++ b/test/e2e/tests/tokens/asset-sticky-actions.spec.ts @@ -52,13 +52,7 @@ describe('Asset sticky actions', function () { await stickyActions.checkPageIsLoaded(); await stickyActions.checkPinnedToViewportBottom(); - // Scroll far enough that a non-sticky footer would leave the viewport. - await driver.executeScript(` - const scroller = document.querySelector('.main-container.asset__container'); - if (scroller) { - scroller.scrollTo(0, scroller.scrollHeight); - } - `); + await stickyActions.scrollToBottom(); await stickyActions.checkPageIsLoaded(); await stickyActions.checkPinnedToViewportBottom(); diff --git a/ui/pages/asset/asset.tsx b/ui/pages/asset/asset.tsx index 79ac9d0ffd6f..feced278cc03 100644 --- a/ui/pages/asset/asset.tsx +++ b/ui/pages/asset/asset.tsx @@ -107,7 +107,10 @@ const Asset = () => { ]); return ( - + {renderContent()} ); diff --git a/ui/pages/asset/security-trust/security-trust-page.tsx b/ui/pages/asset/security-trust/security-trust-page.tsx index 0f4e98867c13..965b485573fd 100644 --- a/ui/pages/asset/security-trust/security-trust-page.tsx +++ b/ui/pages/asset/security-trust/security-trust-page.tsx @@ -678,7 +678,10 @@ const SecurityTrustPage = () => { ); return ( - + {pageContent} ); From 05d4325d30f7b5809b3f28ad057aac3c77e48a05 Mon Sep 17 00:00:00 2001 From: salimtb Date: Tue, 18 Aug 2026 17:17:08 +0200 Subject: [PATCH 6/6] test(e2e): link tracking issue for getSubscriptions suppression Address review feedback: the SubscriptionsController startup race that the `ignoredConsoleErrors` entry hides is now tracked in https://github.com/MetaMask/metamask-extension/issues/45612 rather than explained by a code comment alone. Co-authored-by: Cursor --- test/e2e/tests/tokens/asset-sticky-actions.spec.ts | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/test/e2e/tests/tokens/asset-sticky-actions.spec.ts b/test/e2e/tests/tokens/asset-sticky-actions.spec.ts index 8660ac275028..1d05e2b8b459 100644 --- a/test/e2e/tests/tokens/asset-sticky-actions.spec.ts +++ b/test/e2e/tests/tokens/asset-sticky-actions.spec.ts @@ -23,7 +23,8 @@ describe('Asset sticky actions', function () { .build(), title: (this as Context).test?.fullTitle(), ethConversionInUsd: 1700, - // Known SubscriptionsController startup race; unrelated to this page. + // Known SubscriptionsController startup race, unrelated to this page. + // Tracked in https://github.com/MetaMask/metamask-extension/issues/45612 ignoredConsoleErrors: ['getSubscriptions'], localNodeOptions: { chainId: parseInt(chainId, 16),