From f3e68e2880d7f68582bd0224e08ea97f5494eefd Mon Sep 17 00:00:00 2001 From: Charly Chevalier Date: Wed, 10 Jun 2026 14:03:58 +0200 Subject: [PATCH 01/15] feat: add `snap-account-service` (#30329) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ## **Description** Integrating our new `SnapAccountService` before we start migrating the old `SnapKeyring` with the new `SnapKeyringV2`. ## **Changelog** CHANGELOG entry: null ## **Related issues** Fixes: N/A ## **Manual testing steps** Not applicable ## **Screenshots/Recordings** Not applicable ## **Pre-merge author checklist** - [x] I've followed [MetaMask Contributor Docs](https://github.com/MetaMask/contributor-docs) and [MetaMask Mobile Coding Standards](https://github.com/MetaMask/metamask-mobile/blob/main/.github/guidelines/CODING_GUIDELINES.md). - [x] I've completed the PR template to the best of my ability - [x] I've included tests if applicable - [x] I've documented my code using [JSDoc](https://jsdoc.app/) format if applicable - [x] I've applied the right labels on the PR (see [labeling guidelines](https://github.com/MetaMask/metamask-mobile/blob/main/.github/guidelines/LABELING_GUIDELINES.md)). Not required for external contributors. #### Performance checks (if applicable) - [x] I've tested on Android - Ideally on a mid-range device; emulator is acceptable - [x] I've tested with a power user scenario - Use these [power-user SRPs](https://consensyssoftware.atlassian.net/wiki/spaces/TL1/pages/edit-v2/401401446401?draftShareId=9d77e1e1-4bdc-4be1-9ebb-ccd916988d93) to import wallets with many accounts and tokens - [x] I've instrumented key operations with Sentry traces for production performance metrics - See [`trace()`](/app/util/trace.ts) for usage and [`addToken`](/app/components/Views/AddAsset/components/AddCustomToken/AddCustomToken.tsx#L274) for an example For performance guidelines and tooling, see the [Performance Guide](https://consensyssoftware.atlassian.net/wiki/spaces/TL1/pages/400085549067/Performance+Guide+for+Engineers). ## **Pre-merge reviewer checklist** - [ ] I've manually tested the PR (e.g. pull and build branch, run the app, test code being changed). - [ ] I confirm that this PR addresses all acceptance criteria described in the ticket it closes and includes the necessary testing evidence such as recordings and or screenshots. --- > [!NOTE] > **High Risk** > Touches snap keyring creation, permission RPC keyring hooks, and multichain routing—security-sensitive paths where regressions could break snap accounts or deadlock keyring access. > > **Overview** > Wires **`@metamask/snap-account-service`** into the Engine as a first-class context module (init, scoped messenger, stateless registration) so snap account lifecycle and keyring messaging can live in one place ahead of **SnapKeyringV2**. > > **Snap keyring access** no longer duplicates `KeyringController:getKeyringsByType` / `addNewKeyring` in Engine, multichain routing, or snap permission specs. Callers use **`SnapAccountService:getLegacySnapKeyring`** and **`handleKeyringSnapMessage`** instead; **`AccountsController`** now listens for **`SnapAccountService:*`** account update events rather than legacy SnapKeyring constants. > > **Onboarding gating** for snaps is extracted to shared **`ensureOnboardingComplete`** (used by SnapController and SnapAccountService init). **Mnemonic / keyring** paths in snap permissions move to **`withKeyringV2Unsafe`** and v2 HD/Ledger types. Dependency bumps include **`accounts-controller`**, **`multichain-account-service`**, **`keyring-sdk`**, and new **`snap-account-service`**, plus **tsconfig** path aliases for keyring v2 packages. > > Reviewed by [Cursor Bugbot](https://cursor.com/bugbot) for commit 72523b4235d19a08503349577ef45cf65468d88e. Bugbot is set up for automated code reviews on this repo. Configure [here](https://www.cursor.com/dashboard/bugbot). --- app/core/Engine/Engine.test.ts | 35 +--- app/core/Engine/Engine.ts | 25 +-- app/core/Engine/constants.ts | 1 + .../multichain-routing-service-init.ts | 37 +--- .../snap-account-service-init.test.ts | 54 +++++ .../snap-account-service-init.ts | 27 +++ .../controllers/snaps/snap-controller-init.ts | 37 +--- .../accounts-controller-messenger/index.ts | 11 +- app/core/Engine/messengers/index.ts | 5 + .../multichain-account-service-messenger.ts | 7 +- .../multichain-routing-service-messenger.ts | 13 +- .../permission-controller-messenger.ts | 7 +- .../snap-account-service-messenger.test.ts | 136 +++++++++++++ .../snap-account-service-messenger.ts | 62 ++++++ .../snaps/snap-controller-messenger.ts | 6 +- app/core/Engine/types.ts | 9 + .../utils/ensureOnboardingComplete.test.ts | 111 ++++++++++ .../Engine/utils/ensureOnboardingComplete.ts | 46 +++++ .../Snaps/permissions/specifications.test.ts | 128 ++++++++++++ app/core/Snaps/permissions/specifications.ts | 42 ++-- app/core/Snaps/permissions/utils.test.ts | 22 +- app/core/Snaps/permissions/utils.ts | 22 +- package.json | 14 +- tsconfig.json | 16 ++ yarn.lock | 190 ++++-------------- 25 files changed, 726 insertions(+), 337 deletions(-) create mode 100644 app/core/Engine/controllers/snap-account-service/snap-account-service-init.test.ts create mode 100644 app/core/Engine/controllers/snap-account-service/snap-account-service-init.ts create mode 100644 app/core/Engine/messengers/snap-account-service-messenger/snap-account-service-messenger.test.ts create mode 100644 app/core/Engine/messengers/snap-account-service-messenger/snap-account-service-messenger.ts create mode 100644 app/core/Engine/utils/ensureOnboardingComplete.test.ts create mode 100644 app/core/Engine/utils/ensureOnboardingComplete.ts create mode 100644 app/core/Snaps/permissions/specifications.test.ts diff --git a/app/core/Engine/Engine.test.ts b/app/core/Engine/Engine.test.ts index 788da2e0fb7..5302792ec93 100644 --- a/app/core/Engine/Engine.test.ts +++ b/app/core/Engine/Engine.test.ts @@ -322,42 +322,29 @@ describe('Engine', () => { ); }); - it('getSnapKeyring gets or creates a snap keyring', async () => { + it('getSnapKeyring delegates to SnapAccountService.getLegacySnapKeyring', async () => { const engine = new EngineClass(TEST_ANALYTICS_ID, backgroundState); const mockSnapKeyring = { type: 'Snap Keyring' } as unknown as SnapKeyring; - jest - .spyOn(engine.keyringController, 'getKeyringsByType') - .mockImplementation(() => [mockSnapKeyring]); - const getSnapKeyringSpy = jest - .spyOn(engine, 'getSnapKeyring') - .mockImplementation(async () => mockSnapKeyring); + jest + .spyOn(engine.context.SnapAccountService, 'getLegacySnapKeyring') + .mockResolvedValue(mockSnapKeyring as never); const result = await engine.getSnapKeyring(); - expect(getSnapKeyringSpy).toHaveBeenCalled(); + expect(result).toEqual(mockSnapKeyring); }); - it('getSnapKeyring creates a new snap keyring if none exists', async () => { + it('getSnapKeyring propagates errors from SnapAccountService.getLegacySnapKeyring', async () => { const engine = new EngineClass(TEST_ANALYTICS_ID, backgroundState); - const mockSnapKeyring = { type: 'Snap Keyring' } as unknown as SnapKeyring; - - jest - .spyOn(engine.keyringController, 'getKeyringsByType') - .mockImplementationOnce(() => []) - .mockImplementationOnce(() => [mockSnapKeyring]); jest - .spyOn(engine.keyringController, 'addNewKeyring') - .mockResolvedValue({ id: '1234', name: 'Snap Keyring' }); - - const getSnapKeyringSpy = jest - .spyOn(engine, 'getSnapKeyring') - .mockImplementation(async () => mockSnapKeyring); + .spyOn(engine.context.SnapAccountService, 'getLegacySnapKeyring') + .mockRejectedValue(new Error('keyring unavailable')); - const result = await engine.getSnapKeyring(); - expect(getSnapKeyringSpy).toHaveBeenCalled(); - expect(result).toEqual(mockSnapKeyring); + await expect(engine.getSnapKeyring()).rejects.toThrow( + 'keyring unavailable', + ); }); it('enables the RPC failover feature if the walletFrameworkRpcFailoverEnabled feature flag is already enabled', () => { diff --git a/app/core/Engine/Engine.ts b/app/core/Engine/Engine.ts index aaf76a062cd..6fac2725d91 100644 --- a/app/core/Engine/Engine.ts +++ b/app/core/Engine/Engine.ts @@ -17,9 +17,6 @@ import { AccountsController } from '@metamask/accounts-controller'; import { KeyringController, KeyringControllerState, - ///: BEGIN:ONLY_INCLUDE_IF(snaps) - KeyringTypes, - ///: END:ONLY_INCLUDE_IF } from '@metamask/keyring-controller'; import { NetworkState, NetworkStatus } from '@metamask/network-controller'; import { @@ -36,10 +33,6 @@ import { SubjectMetadataController, ///: END:ONLY_INCLUDE_IF } from '@metamask/permission-controller'; -import { - QrKeyring, - QrKeyringDeferredPromiseBridge, -} from '@metamask/eth-qr-keyring'; import { isTestNet } from '../../util/networks'; import { deprecatedGetNetworkId } from '../../util/networks/engineNetworkUtils'; import AppConstants from '../AppConstants'; @@ -102,6 +95,7 @@ import { multichainAssetsControllerInit } from './controllers/multichain-assets- import { multichainAssetsRatesControllerInit } from './controllers/multichain-assets-rates-controller/multichain-assets-rates-controller-init'; import { multichainTransactionsControllerInit } from './controllers/multichain-transactions-controller/multichain-transactions-controller-init'; import { multichainAccountServiceInit } from './controllers/multichain-account-service/multichain-account-service-init'; +import { snapAccountServiceInit } from './controllers/snap-account-service/snap-account-service-init'; import { SnapKeyring } from '@metamask/eth-snap-keyring'; ///: END:ONLY_INCLUDE_IF ///: BEGIN:ONLY_INCLUDE_IF(snaps) @@ -378,6 +372,7 @@ export class Engine { AccountActivityService: accountActivityServiceInit, OHLCVService: ohlcvServiceInit, ///: BEGIN:ONLY_INCLUDE_IF(keyring-snaps) + SnapAccountService: snapAccountServiceInit, MultichainAssetsController: multichainAssetsControllerInit, MultichainAssetsRatesController: multichainAssetsRatesControllerInit, MultichainBalancesController: multichainBalancesControllerInit, @@ -544,6 +539,7 @@ export class Engine { messengerClientsByName.MultichainTransactionsController; const multichainAccountService = messengerClientsByName.MultichainAccountService; + const snapAccountService = messengerClientsByName.SnapAccountService; ///: END:ONLY_INCLUDE_IF const networkEnablementController = @@ -617,6 +613,7 @@ export class Engine { MultichainAssetsRatesController: multichainAssetsRatesController, MultichainTransactionsController: multichainTransactionsController, MultichainAccountService: multichainAccountService, + SnapAccountService: snapAccountService, ///: END:ONLY_INCLUDE_IF TokenSearchDiscoveryDataController: tokenSearchDiscoveryDataController, MultichainNetworkController: multichainNetworkController, @@ -1150,18 +1147,8 @@ export class Engine { ///: BEGIN:ONLY_INCLUDE_IF(keyring-snaps) getSnapKeyring = async (): Promise => { - // TODO: Replace `getKeyringsByType` with `withKeyring` - let [snapKeyring] = this.keyringController.getKeyringsByType( - KeyringTypes.snap, - ); - if (!snapKeyring) { - await this.keyringController.addNewKeyring(KeyringTypes.snap); - // TODO: Replace `getKeyringsByType` with `withKeyring` - [snapKeyring] = this.keyringController.getKeyringsByType( - KeyringTypes.snap, - ); - } - return snapKeyring as SnapKeyring; + const { SnapAccountService } = this.context; + return await SnapAccountService.getLegacySnapKeyring(); }; /** diff --git a/app/core/Engine/constants.ts b/app/core/Engine/constants.ts index 5895151d60d..836927f2047 100644 --- a/app/core/Engine/constants.ts +++ b/app/core/Engine/constants.ts @@ -16,6 +16,7 @@ export const STATELESS_NON_CONTROLLER_NAMES = [ 'AccountActivityService', 'OHLCVService', 'MultichainAccountService', + 'SnapAccountService', 'GeolocationApiService', 'ProfileMetricsService', 'RampsService', diff --git a/app/core/Engine/controllers/multichain-routing-service-init.ts b/app/core/Engine/controllers/multichain-routing-service-init.ts index 5767e64e8ee..19e97a7c20f 100644 --- a/app/core/Engine/controllers/multichain-routing-service-init.ts +++ b/app/core/Engine/controllers/multichain-routing-service-init.ts @@ -5,7 +5,6 @@ import { } from '@metamask/snaps-controllers'; import { MultichainRoutingServiceInitMessenger } from '../messengers/multichain-routing-service-messenger'; import { SnapKeyring } from '@metamask/eth-snap-keyring'; -import { KeyringTypes } from '@metamask/keyring-controller'; /** * Initialize the multichain routing service. @@ -19,44 +18,18 @@ export const multichainRoutingServiceInit: MessengerClientInitFunction< MultichainRoutingServiceMessenger, MultichainRoutingServiceInitMessenger > = ({ controllerMessenger, initMessenger }) => { - const getSnapKeyring = async (): Promise => { - // TODO: Replace `getKeyringsByType` with `withKeyring` - let [snapKeyring] = initMessenger.call( - 'KeyringController:getKeyringsByType', - KeyringTypes.snap, - ); - - if (!snapKeyring) { - await initMessenger.call( - 'KeyringController:addNewKeyring', - KeyringTypes.snap, - ); - - // TODO: Replace `getKeyringsByType` with `withKeyring` - [snapKeyring] = initMessenger.call( - 'KeyringController:getKeyringsByType', - KeyringTypes.snap, - ); - } - return snapKeyring as SnapKeyring; - }; - - // This fixes an issue where `withKeyring` would lock the `KeyringController` - // mutex. That meant that if a snap requested a keyring operation (like - // requesting entropy) while the `KeyringController` was locked, it would - // cause a deadlock. This is a temporary fix until we can refactor how we - // handle requests to the Snaps Keyring. - const withSnapKeyring = async ( - operation: ({ keyring }: { keyring: unknown }) => void, + const withSnapKeyring = async ( + operation: ({ keyring }: { keyring: SnapKeyring }) => Promise, ) => { - const keyring = await getSnapKeyring(); + const keyring = await initMessenger.call( + 'SnapAccountService:getLegacySnapKeyring', + ); return operation({ keyring }); }; const controller = new MultichainRoutingService({ messenger: controllerMessenger, - // @ts-expect-error: Type for `withSnapKeyring` is different. withSnapKeyring, }); diff --git a/app/core/Engine/controllers/snap-account-service/snap-account-service-init.test.ts b/app/core/Engine/controllers/snap-account-service/snap-account-service-init.test.ts new file mode 100644 index 00000000000..78f4eb4de77 --- /dev/null +++ b/app/core/Engine/controllers/snap-account-service/snap-account-service-init.test.ts @@ -0,0 +1,54 @@ +import { SnapAccountService } from '@metamask/snap-account-service'; +import { MOCK_ANY_NAMESPACE, MockAnyNamespace } from '@metamask/messenger'; +import { ExtendedMessenger } from '../../../ExtendedMessenger'; +import { buildMessengerClientInitRequestMock } from '../../utils/test-utils'; +import { getSnapAccountServiceMessenger } from '../../messengers/snap-account-service-messenger/snap-account-service-messenger'; +import { snapAccountServiceInit } from './snap-account-service-init'; + +jest.mock('@metamask/snap-account-service'); +jest.mock('../../utils/ensureOnboardingComplete', () => ({ + ensureOnboardingComplete: jest.fn(() => jest.fn()), +})); + +function getInitRequestMock() { + const baseMessenger = new ExtendedMessenger({ + namespace: MOCK_ANY_NAMESPACE, + }); + + return { + ...buildMessengerClientInitRequestMock(baseMessenger), + controllerMessenger: getSnapAccountServiceMessenger(baseMessenger), + initMessenger: undefined, + }; +} + +describe('snapAccountServiceInit', () => { + beforeEach(() => { + jest.clearAllMocks(); + }); + + it('constructs a SnapAccountService with the controller messenger', () => { + const requestMock = getInitRequestMock(); + + snapAccountServiceInit(requestMock); + + expect(SnapAccountService).toHaveBeenCalledTimes(1); + expect(SnapAccountService).toHaveBeenCalledWith({ + messenger: requestMock.controllerMessenger, + config: { + snapPlatformWatcher: { + ensureOnboardingComplete: expect.any(Function), + }, + }, + }); + }); + + it('returns the constructed service as the controller', () => { + const requestMock = getInitRequestMock(); + + const result = snapAccountServiceInit(requestMock); + + const constructed = jest.mocked(SnapAccountService).mock.instances[0]; + expect(result.controller).toBe(constructed); + }); +}); diff --git a/app/core/Engine/controllers/snap-account-service/snap-account-service-init.ts b/app/core/Engine/controllers/snap-account-service/snap-account-service-init.ts new file mode 100644 index 00000000000..877195ac805 --- /dev/null +++ b/app/core/Engine/controllers/snap-account-service/snap-account-service-init.ts @@ -0,0 +1,27 @@ +import { SnapAccountService } from '@metamask/snap-account-service'; +import type { MessengerClientInitFunction } from '../../types'; +import type { SnapAccountServiceMessenger } from '../../messengers/snap-account-service-messenger/snap-account-service-messenger'; +import { ensureOnboardingComplete } from '../../utils/ensureOnboardingComplete'; + +/** + * Initialize the Snap account service. + * + * @param request - The request object. + * @param request.controllerMessenger - The messenger to use for the service. + * @returns The initialized service. + */ +export const snapAccountServiceInit: MessengerClientInitFunction< + SnapAccountService, + SnapAccountServiceMessenger +> = ({ controllerMessenger }) => { + const controller = new SnapAccountService({ + messenger: controllerMessenger, + config: { + snapPlatformWatcher: { + ensureOnboardingComplete, + }, + }, + }); + + return { controller }; +}; diff --git a/app/core/Engine/controllers/snaps/snap-controller-init.ts b/app/core/Engine/controllers/snaps/snap-controller-init.ts index a0078970a21..001f2e02602 100644 --- a/app/core/Engine/controllers/snaps/snap-controller-init.ts +++ b/app/core/Engine/controllers/snaps/snap-controller-init.ts @@ -18,18 +18,12 @@ import { pbkdf2, } from '../../../Encryptor'; import { selectBasicFunctionalityEnabled } from '../../../../selectors/settings'; -import { store, runSaga } from '../../../../store'; +import { store } from '../../../../store'; import PREINSTALLED_SNAPS from '../../../../lib/snaps/preinstalled-snaps'; import { buildAndTrackEvent } from '../../utils/analytics'; import type { AnalyticsUnfilteredProperties } from '../../../../util/analytics/analytics.types'; -import { take } from 'redux-saga/effects'; -import { selectCompletedOnboarding } from '../../../../selectors/onboarding'; -import { - SET_COMPLETED_ONBOARDING, - SetCompletedOnboardingAction, -} from '../../../../actions/onboarding'; -import { SagaIterator } from 'redux-saga'; import { getMnemonicSeed } from '../../../Snaps/permissions/utils'; +import { ensureOnboardingComplete } from '../../utils/ensureOnboardingComplete'; import { CAN_INSTALL_THIRD_PARTY_SNAPS } from '../../../../constants/snaps'; /** @@ -76,33 +70,6 @@ export const snapControllerInit: MessengerClientInitFunction< }; } - function* ensureOnboardingCompleteSaga(): SagaIterator { - while (true) { - const result = (yield take([ - SET_COMPLETED_ONBOARDING, - ])) as SetCompletedOnboardingAction; - - if (result.completedOnboarding) { - return; - } - } - } - - let onboardingPromise: Promise | null = null; - - async function ensureOnboardingComplete() { - if (selectCompletedOnboarding(store.getState())) { - return; - } - - if (!onboardingPromise) { - onboardingPromise = runSaga(ensureOnboardingCompleteSaga).toPromise(); - } - - await onboardingPromise; - onboardingPromise = null; - } - const controller = new SnapController({ environmentEndowmentPermissions: Object.values(EndowmentPermissions), excludedPermissions: { diff --git a/app/core/Engine/messengers/accounts-controller-messenger/index.ts b/app/core/Engine/messengers/accounts-controller-messenger/index.ts index 25dabc7df88..6a66595ea92 100644 --- a/app/core/Engine/messengers/accounts-controller-messenger/index.ts +++ b/app/core/Engine/messengers/accounts-controller-messenger/index.ts @@ -1,10 +1,5 @@ import { AccountsControllerMessenger } from '@metamask/accounts-controller'; import { RootExtendedMessenger, RootMessenger } from '../../types'; -import { - SnapKeyringAccountAssetListUpdatedEvent, - SnapKeyringAccountBalancesUpdatedEvent, - SnapKeyringAccountTransactionsUpdatedEvent, -} from '../../../SnapKeyring/constants'; import { Messenger, MessengerActions, @@ -40,9 +35,9 @@ export function getAccountsControllerMessenger( ], events: [ 'KeyringController:stateChange', - SnapKeyringAccountAssetListUpdatedEvent, - SnapKeyringAccountBalancesUpdatedEvent, - SnapKeyringAccountTransactionsUpdatedEvent, + 'SnapAccountService:accountAssetListUpdated', + 'SnapAccountService:accountBalancesUpdated', + 'SnapAccountService:accountTransactionsUpdated', 'MultichainNetworkController:networkDidChange', ], messenger, diff --git a/app/core/Engine/messengers/index.ts b/app/core/Engine/messengers/index.ts index 0275760bb56..74f8882a132 100644 --- a/app/core/Engine/messengers/index.ts +++ b/app/core/Engine/messengers/index.ts @@ -34,6 +34,7 @@ import { getMultichainAssetsRatesControllerMessenger } from './multichain-assets import { getMultichainAssetsControllerMessenger } from './multichain-assets-controller-messenger/multichain-assets-controller-messenger'; import { getMultichainBalancesControllerMessenger } from './multichain-balances-controller-messenger/multichain-balances-controller-messenger'; import { getMultichainTransactionsControllerMessenger } from './multichain-transactions-controller-messenger/multichain-transactions-controller-messenger'; +import { getSnapAccountServiceMessenger } from './snap-account-service-messenger/snap-account-service-messenger'; ///: END:ONLY_INCLUDE_IF import { getTransactionControllerInitMessenger, @@ -336,6 +337,10 @@ export const MESSENGER_FACTORIES = { getMessenger: getMultichainTransactionsControllerMessenger, getInitMessenger: noop, }, + SnapAccountService: { + getMessenger: getSnapAccountServiceMessenger, + getInitMessenger: noop, + }, ///: END:ONLY_INCLUDE_IF PermissionController: { getMessenger: getPermissionControllerMessenger, diff --git a/app/core/Engine/messengers/multichain-account-service-messenger/multichain-account-service-messenger.ts b/app/core/Engine/messengers/multichain-account-service-messenger/multichain-account-service-messenger.ts index 0d74361a3c7..87ef7f39c4f 100644 --- a/app/core/Engine/messengers/multichain-account-service-messenger/multichain-account-service-messenger.ts +++ b/app/core/Engine/messengers/multichain-account-service-messenger/multichain-account-service-messenger.ts @@ -34,22 +34,21 @@ export function getMultichainAccountServiceMessenger( 'AccountsController:getAccountByAddress', 'AccountsController:getAccount', 'AccountsController:getAccounts', - 'SnapController:handleRequest', 'KeyringController:getState', 'KeyringController:withKeyring', + 'KeyringController:withKeyringV2', 'KeyringController:addNewKeyring', 'KeyringController:getKeyringsByType', 'KeyringController:createNewVaultAndKeychain', 'KeyringController:createNewVaultAndRestore', 'NetworkController:getNetworkClientById', 'NetworkController:findNetworkClientIdByChainId', - 'SnapController:getState', + 'SnapController:handleRequest', + 'SnapAccountService:ensureReady', ], events: [ - 'KeyringController:stateChange', 'AccountsController:accountAdded', 'AccountsController:accountRemoved', - 'SnapController:stateChange', ], messenger, }); diff --git a/app/core/Engine/messengers/multichain-routing-service-messenger.ts b/app/core/Engine/messengers/multichain-routing-service-messenger.ts index 5c731a57c6c..29f1ea410e0 100644 --- a/app/core/Engine/messengers/multichain-routing-service-messenger.ts +++ b/app/core/Engine/messengers/multichain-routing-service-messenger.ts @@ -1,10 +1,7 @@ import { Messenger } from '@metamask/messenger'; import { MultichainRoutingServiceMessenger } from '@metamask/snaps-controllers'; -import { - KeyringControllerAddNewKeyringAction, - KeyringControllerGetKeyringsByTypeAction, -} from '@metamask/keyring-controller'; import { RootMessenger } from '../types'; +import { SnapAccountServiceGetLegacySnapKeyringAction } from '@metamask/snap-account-service'; /** * Get the multichain routing service messenger for the multichain routing @@ -36,8 +33,7 @@ export function getMultichainRoutingServiceMessenger( } type AllowedInitializationActions = - | KeyringControllerAddNewKeyringAction - | KeyringControllerGetKeyringsByTypeAction; + SnapAccountServiceGetLegacySnapKeyringAction; export type MultichainRoutingServiceInitMessenger = ReturnType< typeof getMultichainRoutingServiceInitMessenger @@ -65,10 +61,7 @@ export function getMultichainRoutingServiceInitMessenger( }); rootMessenger.delegate({ - actions: [ - 'KeyringController:addNewKeyring', - 'KeyringController:getKeyringsByType', - ], + actions: ['SnapAccountService:getLegacySnapKeyring'], events: [], messenger, }); diff --git a/app/core/Engine/messengers/permission-controller-messenger.ts b/app/core/Engine/messengers/permission-controller-messenger.ts index 959ac70972e..92510b16b6a 100644 --- a/app/core/Engine/messengers/permission-controller-messenger.ts +++ b/app/core/Engine/messengers/permission-controller-messenger.ts @@ -18,6 +18,7 @@ import { import { NetworkControllerFindNetworkClientIdByChainIdAction } from '@metamask/network-controller'; import { RootMessenger } from '../types.ts'; import { PermissionControllerMessenger } from '@metamask/permission-controller'; +import { SnapAccountServiceHandleKeyringSnapMessageAction } from '@metamask/snap-account-service'; /** * Get the messenger for the permission controller. This is scoped to the @@ -61,7 +62,8 @@ type AllowedInitializationActions = | MultichainRoutingServiceIsSupportedScopeAction | MultichainRoutingServiceGetSupportedAccountsAction | NetworkControllerFindNetworkClientIdByChainIdAction - | SnapPermissionSpecificationsActions; + | SnapPermissionSpecificationsActions + | SnapAccountServiceHandleKeyringSnapMessageAction; type AllowedInitializationEvents = SnapPermissionSpecificationsEvents; @@ -94,9 +96,9 @@ export function getPermissionControllerInitMessenger( 'ApprovalController:addRequest', 'AccountsController:listAccounts', 'CurrencyRateController:getState', - 'KeyringController:getKeyringsByType', 'KeyringController:getState', 'KeyringController:withKeyring', + 'KeyringController:withKeyringV2Unsafe', 'MultichainRoutingService:isSupportedScope', 'MultichainRoutingService:getSupportedAccounts', 'NetworkController:findNetworkClientIdByChainId', @@ -113,6 +115,7 @@ export function getPermissionControllerInitMessenger( 'SnapInterfaceController:getInterface', 'SnapInterfaceController:setInterfaceDisplayed', 'SnapInterfaceController:updateInterface', + 'SnapAccountService:handleKeyringSnapMessage', ], events: ['KeyringController:unlock'], messenger, diff --git a/app/core/Engine/messengers/snap-account-service-messenger/snap-account-service-messenger.test.ts b/app/core/Engine/messengers/snap-account-service-messenger/snap-account-service-messenger.test.ts new file mode 100644 index 00000000000..6d9198b4ba7 --- /dev/null +++ b/app/core/Engine/messengers/snap-account-service-messenger/snap-account-service-messenger.test.ts @@ -0,0 +1,136 @@ +import { + Messenger, + type MessengerActions, + type MessengerEvents, + MOCK_ANY_NAMESPACE, + type MockAnyNamespace, +} from '@metamask/messenger'; +import type { SnapAccountServiceMessenger } from '@metamask/snap-account-service'; +import { getSnapAccountServiceMessenger } from './snap-account-service-messenger'; + +type RootMessenger = Messenger< + MockAnyNamespace, + MessengerActions, + MessengerEvents +>; + +function getRootMessenger(): RootMessenger { + return new Messenger({ + namespace: MOCK_ANY_NAMESPACE, + }); +} + +describe('getSnapAccountServiceMessenger', () => { + it('returns a messenger', () => { + const rootMessenger = getRootMessenger(); + const snapAccountServiceMessenger = + getSnapAccountServiceMessenger(rootMessenger); + + expect(snapAccountServiceMessenger).toBeInstanceOf(Messenger); + }); + + it('delegates the KeyringController actions to the scoped messenger', () => { + const rootMessenger = getRootMessenger(); + const delegateSpy = jest.spyOn(rootMessenger, 'delegate'); + + getSnapAccountServiceMessenger(rootMessenger); + + expect(delegateSpy).toHaveBeenCalledWith( + expect.objectContaining({ + actions: expect.arrayContaining([ + 'KeyringController:withController', + 'KeyringController:getState', + 'KeyringController:withKeyringUnsafe', + ]), + }), + ); + }); + + it('delegates the SnapController actions to the scoped messenger', () => { + const rootMessenger = getRootMessenger(); + const delegateSpy = jest.spyOn(rootMessenger, 'delegate'); + + getSnapAccountServiceMessenger(rootMessenger); + + expect(delegateSpy).toHaveBeenCalledWith( + expect.objectContaining({ + actions: expect.arrayContaining([ + 'SnapController:getState', + 'SnapController:getSnap', + 'SnapController:getRunnableSnaps', + ]), + }), + ); + }); + + it('delegates the AccountTreeController actions to the scoped messenger', () => { + const rootMessenger = getRootMessenger(); + const delegateSpy = jest.spyOn(rootMessenger, 'delegate'); + + getSnapAccountServiceMessenger(rootMessenger); + + expect(delegateSpy).toHaveBeenCalledWith( + expect.objectContaining({ + actions: expect.arrayContaining([ + 'AccountTreeController:getAccountGroupObject', + 'AccountTreeController:getSelectedAccountGroup', + ]), + }), + ); + }); + + it('delegates the KeyringController events to the scoped messenger', () => { + const rootMessenger = getRootMessenger(); + const delegateSpy = jest.spyOn(rootMessenger, 'delegate'); + + getSnapAccountServiceMessenger(rootMessenger); + + expect(delegateSpy).toHaveBeenCalledWith( + expect.objectContaining({ + events: expect.arrayContaining([ + 'KeyringController:stateChange', + 'KeyringController:unlock', + ]), + }), + ); + }); + + it('delegates the SnapController events to the scoped messenger', () => { + const rootMessenger = getRootMessenger(); + const delegateSpy = jest.spyOn(rootMessenger, 'delegate'); + + getSnapAccountServiceMessenger(rootMessenger); + + expect(delegateSpy).toHaveBeenCalledWith( + expect.objectContaining({ + events: expect.arrayContaining([ + 'SnapController:stateChange', + 'SnapController:snapInstalled', + 'SnapController:snapEnabled', + 'SnapController:snapDisabled', + 'SnapController:snapBlocked', + 'SnapController:snapUnblocked', + 'SnapController:snapUninstalled', + ]), + }), + ); + }); + + it('delegates the AccountTreeController events to the scoped messenger', () => { + const rootMessenger = getRootMessenger(); + const delegateSpy = jest.spyOn(rootMessenger, 'delegate'); + + getSnapAccountServiceMessenger(rootMessenger); + + expect(delegateSpy).toHaveBeenCalledWith( + expect.objectContaining({ + events: expect.arrayContaining([ + 'AccountTreeController:selectedAccountGroupChange', + 'AccountTreeController:accountGroupCreated', + 'AccountTreeController:accountGroupUpdated', + 'AccountTreeController:accountGroupRemoved', + ]), + }), + ); + }); +}); diff --git a/app/core/Engine/messengers/snap-account-service-messenger/snap-account-service-messenger.ts b/app/core/Engine/messengers/snap-account-service-messenger/snap-account-service-messenger.ts new file mode 100644 index 00000000000..592101f8951 --- /dev/null +++ b/app/core/Engine/messengers/snap-account-service-messenger/snap-account-service-messenger.ts @@ -0,0 +1,62 @@ +import { + Messenger, + MessengerActions, + MessengerEvents, +} from '@metamask/messenger'; +import type { SnapAccountServiceMessenger as ServiceMessenger } from '@metamask/snap-account-service'; +import type { RootMessenger } from '../../types'; + +type Actions = MessengerActions; +type Events = MessengerEvents; + +export type SnapAccountServiceMessenger = ServiceMessenger; + +/** + * Get a restricted messenger for the snap account service. This is scoped to + * the actions and events that this service is allowed to handle. + * + * @param rootMessenger - The root messenger. + * @returns The SnapAccountServiceMessenger. + */ +export function getSnapAccountServiceMessenger( + rootMessenger: RootMessenger, +): SnapAccountServiceMessenger { + const messenger = new Messenger< + 'SnapAccountService', + Actions, + Events, + RootMessenger + >({ + namespace: 'SnapAccountService', + parent: rootMessenger, + }); + rootMessenger.delegate({ + messenger, + actions: [ + 'KeyringController:withController', + 'KeyringController:getState', + 'KeyringController:withKeyringUnsafe', + 'SnapController:getState', + 'SnapController:getSnap', + 'SnapController:getRunnableSnaps', + 'AccountTreeController:getAccountGroupObject', + 'AccountTreeController:getSelectedAccountGroup', + ], + events: [ + 'KeyringController:stateChange', + 'KeyringController:unlock', + 'SnapController:stateChange', + 'SnapController:snapInstalled', + 'SnapController:snapEnabled', + 'SnapController:snapDisabled', + 'SnapController:snapBlocked', + 'SnapController:snapUnblocked', + 'SnapController:snapUninstalled', + 'AccountTreeController:selectedAccountGroupChange', + 'AccountTreeController:accountGroupCreated', + 'AccountTreeController:accountGroupUpdated', + 'AccountTreeController:accountGroupRemoved', + ], + }); + return messenger; +} diff --git a/app/core/Engine/messengers/snaps/snap-controller-messenger.ts b/app/core/Engine/messengers/snaps/snap-controller-messenger.ts index 97941699b86..2fb267541d3 100644 --- a/app/core/Engine/messengers/snaps/snap-controller-messenger.ts +++ b/app/core/Engine/messengers/snaps/snap-controller-messenger.ts @@ -2,7 +2,7 @@ import { Messenger } from '@metamask/messenger'; import { KeyringControllerLockEvent, KeyringControllerUnlockEvent, - KeyringControllerWithKeyringAction, + KeyringControllerWithKeyringV2UnsafeAction, } from '@metamask/keyring-controller'; import { PreferencesControllerGetStateAction } from '@metamask/preferences-controller'; import { RootMessenger } from '../../types'; @@ -70,7 +70,7 @@ export function getSnapControllerMessenger(rootMessenger: RootMessenger) { } type InitActions = - | KeyringControllerWithKeyringAction + | KeyringControllerWithKeyringV2UnsafeAction | PreferencesControllerGetStateAction | SnapControllerSetClientActiveAction | AnalyticsControllerActions; @@ -100,7 +100,7 @@ export function getSnapControllerInitMessenger(rootMessenger: RootMessenger) { }); rootMessenger.delegate({ actions: [ - 'KeyringController:withKeyring', + 'KeyringController:withKeyringV2Unsafe', 'PreferencesController:getState', 'SnapController:setClientActive', 'AnalyticsController:trackEvent', diff --git a/app/core/Engine/types.ts b/app/core/Engine/types.ts index 91509b81dd7..17b9c03a296 100644 --- a/app/core/Engine/types.ts +++ b/app/core/Engine/types.ts @@ -377,6 +377,11 @@ import { MultichainAccountServiceActions, MultichainAccountServiceEvents, } from '@metamask/multichain-account-service'; +import type { + SnapAccountService, + SnapAccountServiceActions, + SnapAccountServiceEvents, +} from '@metamask/snap-account-service'; import { GatorPermissionsController, GatorPermissionsControllerActions, @@ -557,6 +562,7 @@ type GlobalActions = | MultichainAssetsRatesControllerActions | MultichainTransactionsControllerActions | MultichainAccountServiceActions + | SnapAccountServiceActions ///: END:ONLY_INCLUDE_IF | AccountsControllerActions | AccountTreeControllerActions @@ -643,6 +649,7 @@ type GlobalEvents = | MultichainAssetsRatesControllerEvents | MultichainTransactionsControllerEvents | MultichainAccountServiceEvents + | SnapAccountServiceEvents ///: END:ONLY_INCLUDE_IF | SignatureControllerEvents | LoggingControllerEvents @@ -792,6 +799,7 @@ export type MessengerClients = { MultichainRoutingService: MultichainRoutingService; MultichainTransactionsController: MultichainTransactionsController; MultichainAccountService: MultichainAccountService; + SnapAccountService: SnapAccountService; ///: END:ONLY_INCLUDE_IF TokenSearchDiscoveryDataController: TokenSearchDiscoveryDataController; MultichainNetworkController: MultichainNetworkController; @@ -967,6 +975,7 @@ export type MessengerClientsToInitialize = | 'MultichainRoutingService' | 'MultichainTransactionsController' | 'MultichainAccountService' + | 'SnapAccountService' ///: END:ONLY_INCLUDE_IF | 'EarnController' | 'MoneyAccountController' diff --git a/app/core/Engine/utils/ensureOnboardingComplete.test.ts b/app/core/Engine/utils/ensureOnboardingComplete.test.ts new file mode 100644 index 00000000000..6934a75ddc2 --- /dev/null +++ b/app/core/Engine/utils/ensureOnboardingComplete.test.ts @@ -0,0 +1,111 @@ +import { RootState } from '../../../reducers'; +import { store, runSaga } from '../../../store'; +import { ensureOnboardingComplete } from './ensureOnboardingComplete'; +import { Task } from 'redux-saga'; + +jest.mock('../../../store', () => ({ + store: { getState: jest.fn() }, + runSaga: jest.fn(), +})); + +const mockRunSaga = jest.mocked(runSaga); +const mockGetState = jest.mocked(store.getState); + +function makeSagaTask(promise: Promise): Task { + return { toPromise: () => promise } as Task; +} + +function setOnboardingComplete(complete: boolean) { + mockGetState.mockReturnValue({ + onboarding: { completedOnboarding: complete }, + } as unknown as RootState); +} + +describe('ensureOnboardingComplete', () => { + beforeEach(() => { + jest.clearAllMocks(); + }); + + describe('when onboarding is already complete', () => { + it('resolves immediately without starting a saga', async () => { + setOnboardingComplete(true); + + await ensureOnboardingComplete(); + + expect(mockRunSaga).not.toHaveBeenCalled(); + }); + }); + + describe('when onboarding is not yet complete', () => { + it('starts a saga and waits for it to complete', async () => { + setOnboardingComplete(false); + mockRunSaga.mockReturnValue(makeSagaTask(Promise.resolve())); + + await ensureOnboardingComplete(); + + expect(mockRunSaga).toHaveBeenCalledTimes(1); + }); + + it('does not resolve until the saga completes', async () => { + setOnboardingComplete(false); + + let resolveSaga!: () => void; + const sagaPromise = new Promise((resolve) => { + resolveSaga = resolve; + }); + mockRunSaga.mockReturnValue(makeSagaTask(sagaPromise)); + + let resolved = false; + const pending = ensureOnboardingComplete().then(() => { + resolved = true; + }); + + expect(resolved).toBe(false); + + resolveSaga(); + await pending; + + expect(resolved).toBe(true); + }); + + it('shares a single saga task across concurrent calls', async () => { + setOnboardingComplete(false); + + let resolveSaga!: () => void; + const sagaPromise = new Promise((resolve) => { + resolveSaga = resolve; + }); + mockRunSaga.mockReturnValue(makeSagaTask(sagaPromise)); + + const p1 = ensureOnboardingComplete(); + const p2 = ensureOnboardingComplete(); + + expect(mockRunSaga).toHaveBeenCalledTimes(1); + + resolveSaga(); + await Promise.all([p1, p2]); + }); + + it('resets the shared promise after resolution so the next call starts a new saga', async () => { + setOnboardingComplete(false); + mockRunSaga.mockReturnValue(makeSagaTask(Promise.resolve())); + + await ensureOnboardingComplete(); + await ensureOnboardingComplete(); + + expect(mockRunSaga).toHaveBeenCalledTimes(2); + }); + + it('resets the shared promise after saga rejection so the next call starts a new saga', async () => { + setOnboardingComplete(false); + mockRunSaga.mockReturnValue( + makeSagaTask(Promise.reject(new Error('saga failed'))), + ); + + await expect(ensureOnboardingComplete()).rejects.toThrow('saga failed'); + await expect(ensureOnboardingComplete()).rejects.toThrow('saga failed'); + + expect(mockRunSaga).toHaveBeenCalledTimes(2); + }); + }); +}); diff --git a/app/core/Engine/utils/ensureOnboardingComplete.ts b/app/core/Engine/utils/ensureOnboardingComplete.ts new file mode 100644 index 00000000000..d413526b6e1 --- /dev/null +++ b/app/core/Engine/utils/ensureOnboardingComplete.ts @@ -0,0 +1,46 @@ +import { take } from 'redux-saga/effects'; +import type { SagaIterator } from 'redux-saga'; +import { runSaga, store } from '../../../store'; +import { selectCompletedOnboarding } from '../../../selectors/onboarding'; +import { + SET_COMPLETED_ONBOARDING, + type SetCompletedOnboardingAction, +} from '../../../actions/onboarding'; + +let onboardingPromise: Promise | null = null; + +function* waitForOnboardingCompleteSaga(): SagaIterator { + while (true) { + const result = (yield take([ + SET_COMPLETED_ONBOARDING, + ])) as SetCompletedOnboardingAction; + + if (result.completedOnboarding) { + return; + } + } +} + +/** + * Resolves when onboarding is complete. Resolves immediately when onboarding + * is already done; otherwise waits for the `SET_COMPLETED_ONBOARDING` action + * with `completedOnboarding: true`. + * + * The pending promise is shared across concurrent callers so a single saga + * task drives the wait. + */ +export async function ensureOnboardingComplete(): Promise { + if (selectCompletedOnboarding(store.getState())) { + return; + } + + if (!onboardingPromise) { + onboardingPromise = runSaga(waitForOnboardingCompleteSaga).toPromise(); + } + + try { + await onboardingPromise; + } finally { + onboardingPromise = null; + } +} diff --git a/app/core/Snaps/permissions/specifications.test.ts b/app/core/Snaps/permissions/specifications.test.ts new file mode 100644 index 00000000000..552b5c3564a --- /dev/null +++ b/app/core/Snaps/permissions/specifications.test.ts @@ -0,0 +1,128 @@ +import { + Messenger, + MOCK_ANY_NAMESPACE, + type MockAnyNamespace, +} from '@metamask/messenger'; +import { buildSnapRestrictedMethodSpecifications } from '@metamask/snaps-rpc-methods'; +import type { SnapMessage } from '@metamask/eth-snap-keyring'; +import type { SnapId } from '@metamask/snaps-sdk'; +import { + SnapPermissionSpecificationsActions, + SnapPermissionSpecificationsEvents, + getSnapPermissionSpecifications, +} from './specifications'; + +jest.mock('@metamask/snaps-rpc-methods', () => ({ + buildSnapEndowmentSpecifications: jest.fn(() => ({})), + buildSnapRestrictedMethodSpecifications: jest.fn(() => ({})), +})); + +const MOCK_SNAP_ID = 'npm:@metamask/test-snap' as SnapId; +const MOCK_MESSAGE: SnapMessage = { method: 'keyring_createAccount' }; + +function getMessenger() { + return new Messenger< + MockAnyNamespace, + SnapPermissionSpecificationsActions, + SnapPermissionSpecificationsEvents + >({ namespace: MOCK_ANY_NAMESPACE }); +} + +function getCapturedOptions() { + const { calls } = jest.mocked(buildSnapRestrictedMethodSpecifications).mock; + expect(calls.length).toBeGreaterThan(0); + // options are the second argument + return calls[calls.length - 1][1] as Record; +} + +describe('getSnapPermissionSpecifications', () => { + beforeEach(() => { + jest.clearAllMocks(); + }); + + describe('getSnapKeyring', () => { + it('passes a getSnapKeyring option to buildSnapRestrictedMethodSpecifications', () => { + const messenger = getMessenger(); + messenger.registerActionHandler( + 'SnapAccountService:handleKeyringSnapMessage', + jest.fn(), + ); + + getSnapPermissionSpecifications(messenger); + + const options = getCapturedOptions(); + expect(typeof options.getSnapKeyring).toBe('function'); + }); + + it('returns a keyring object with a handleKeyringSnapMessage method', async () => { + const messenger = getMessenger(); + messenger.registerActionHandler( + 'SnapAccountService:handleKeyringSnapMessage', + jest.fn().mockResolvedValue(null), + ); + + getSnapPermissionSpecifications(messenger); + + const { getSnapKeyring } = getCapturedOptions() as { + getSnapKeyring: () => Promise; + }; + const keyring = await getSnapKeyring(); + + expect( + typeof (keyring as Record).handleKeyringSnapMessage, + ).toBe('function'); + }); + + it('delegates handleKeyringSnapMessage to SnapAccountService:handleKeyringSnapMessage', async () => { + const messenger = getMessenger(); + const handleMock = jest.fn().mockResolvedValue({ address: '0xabc' }); + messenger.registerActionHandler( + 'SnapAccountService:handleKeyringSnapMessage', + handleMock, + ); + + getSnapPermissionSpecifications(messenger); + + const { getSnapKeyring } = getCapturedOptions() as { + getSnapKeyring: () => Promise<{ + handleKeyringSnapMessage( + snapId: string, + message: SnapMessage, + ): Promise; + }>; + }; + const keyring = await getSnapKeyring(); + await keyring.handleKeyringSnapMessage(MOCK_SNAP_ID, MOCK_MESSAGE); + + expect(handleMock).toHaveBeenCalledTimes(1); + expect(handleMock).toHaveBeenCalledWith(MOCK_SNAP_ID, MOCK_MESSAGE); + }); + + it('returns the result from SnapAccountService:handleKeyringSnapMessage', async () => { + const messenger = getMessenger(); + const expectedResult = { address: '0xabc' }; + messenger.registerActionHandler( + 'SnapAccountService:handleKeyringSnapMessage', + jest.fn().mockResolvedValue(expectedResult), + ); + + getSnapPermissionSpecifications(messenger); + + const { getSnapKeyring } = getCapturedOptions() as { + getSnapKeyring: () => Promise<{ + handleKeyringSnapMessage( + snapId: string, + message: SnapMessage, + ): Promise; + }>; + }; + const keyring = await getSnapKeyring(); + const result = await keyring.handleKeyringSnapMessage( + MOCK_SNAP_ID, + MOCK_MESSAGE, + ); + + expect(result).toBe(expectedResult); + }); + }); +}); diff --git a/app/core/Snaps/permissions/specifications.ts b/app/core/Snaps/permissions/specifications.ts index df9caf60dd9..336189aa8fb 100644 --- a/app/core/Snaps/permissions/specifications.ts +++ b/app/core/Snaps/permissions/specifications.ts @@ -18,11 +18,10 @@ import { } from '@metamask/snaps-controllers'; import { CurrencyRateController } from '@metamask/assets-controllers'; import { - KeyringControllerAddNewKeyringAction, - KeyringControllerGetKeyringsByTypeAction, KeyringControllerGetStateAction, KeyringControllerUnlockEvent, KeyringControllerWithKeyringAction, + KeyringControllerWithKeyringV2UnsafeAction, KeyringTypes, } from '@metamask/keyring-controller'; import { MaybeUpdateState, TestOrigin } from '@metamask/phishing-controller'; @@ -33,6 +32,9 @@ import { hmacSha512 } from '@metamask/native-utils'; import { pbkdf2 } from '../../Encryptor'; import I18n from '../../../../locales/i18n'; import { ExcludedSnapEndowments, ExcludedSnapPermissions } from './permissions'; +import { SnapMessage } from '@metamask/eth-snap-keyring'; +import { SnapId } from '@metamask/snaps-sdk'; +import { SnapAccountServiceHandleKeyringSnapMessageAction } from '@metamask/snap-account-service'; export type SnapPermissionSpecificationsActions = | ApprovalControllerAddRequestAction @@ -46,9 +48,8 @@ export type SnapPermissionSpecificationsActions = | SnapControllerGetSnapAction | SnapControllerGetSnapStateAction | SnapControllerHandleRequestAction - | KeyringControllerGetKeyringsByTypeAction | KeyringControllerWithKeyringAction - | KeyringControllerAddNewKeyringAction + | KeyringControllerWithKeyringV2UnsafeAction | MaybeUpdateState | PreferencesControllerGetStateAction | TestOrigin @@ -56,7 +57,8 @@ export type SnapPermissionSpecificationsActions = | SnapInterfaceControllerUpdateInterfaceAction | KeyringControllerGetStateAction | HasPermission - | SnapInterfaceControllerSetInterfaceDisplayedAction; + | SnapInterfaceControllerSetInterfaceDisplayedAction + | SnapAccountServiceHandleKeyringSnapMessageAction; export type SnapPermissionSpecificationsEvents = KeyringControllerUnlockEvent; @@ -122,27 +124,17 @@ export const getSnapPermissionSpecifications = ( }, ///: END:ONLY_INCLUDE_IF ///: BEGIN:ONLY_INCLUDE_IF(keyring-snaps) - getSnapKeyring: async () => { - // TODO: Replace `getKeyringsByType` with `withKeyring` - let [snapKeyring] = messenger.call( - 'KeyringController:getKeyringsByType', - KeyringTypes.snap, - ); - - if (!snapKeyring) { - await messenger.call( - 'KeyringController:addNewKeyring', - KeyringTypes.snap, + getSnapKeyring: async () => ({ + // We only need a subset of the Snap keyring's functionality, and this message handling is now + // owned by the Snap account service. + handleKeyringSnapMessage(snapId: string, message: SnapMessage) { + return messenger.call( + 'SnapAccountService:handleKeyringSnapMessage', + snapId as SnapId, + message, ); - // TODO: Replace `getKeyringsByType` with `withKeyring` - [snapKeyring] = messenger.call( - 'KeyringController:getKeyringsByType', - KeyringTypes.snap, - ); - } - - return snapKeyring; - }, + }, + }), ///: END:ONLY_INCLUDE_IF }, messenger as RestrictedMethodMessenger, diff --git a/app/core/Snaps/permissions/utils.test.ts b/app/core/Snaps/permissions/utils.test.ts index 1fb644aba5a..9e46dabd8fc 100644 --- a/app/core/Snaps/permissions/utils.test.ts +++ b/app/core/Snaps/permissions/utils.test.ts @@ -1,16 +1,16 @@ import { Messenger, MOCK_ANY_NAMESPACE } from '@metamask/messenger'; import { getMnemonicSeed } from './utils'; -import { KeyringControllerWithKeyringAction } from '@metamask/keyring-controller'; +import { KeyringControllerWithKeyringV2UnsafeAction } from '@metamask/keyring-controller'; import { HdKeyring } from '@metamask/eth-hd-keyring'; +import { HdKeyring as HdKeyringV2 } from '@metamask/eth-hd-keyring/v2'; import { hexToBytes } from '@metamask/utils'; -import { mnemonicPhraseToBytes } from '@metamask/key-tree'; import { mnemonicToSeed } from 'ethers/lib/utils'; -import { Keyring } from '@metamask/keyring-utils'; +import { Keyring } from '@metamask/keyring-api/v2'; import { LedgerKeyring } from '@metamask/eth-ledger-bridge-keyring'; +import { LedgerKeyring as LedgerKeyringV2 } from '@metamask/eth-ledger-bridge-keyring/v2'; const TEST_MNEMONIC = 'test test test test test test test test test test test ball'; -const TEST_MNEMONIC_BYTES = mnemonicPhraseToBytes(TEST_MNEMONIC); const TEST_MNEMONIC_SEED = hexToBytes(mnemonicToSeed(TEST_MNEMONIC)); /** @@ -32,17 +32,23 @@ async function getMessenger(deserialize = true) { const ledgerKeyring = new LedgerKeyring({ bridge: {} }); const keyrings: Record = { - main: hdKeyring, - ledger: ledgerKeyring, + main: new HdKeyringV2({ + legacyKeyring: hdKeyring, + entropySource: 'mock-hd-keyring-id', + }), + ledger: new LedgerKeyringV2({ + legacyKeyring: ledgerKeyring, + entropySource: 'mock-ledger-keyring-id', + }), }; const messenger = new Messenger< string, - KeyringControllerWithKeyringAction, + KeyringControllerWithKeyringV2UnsafeAction, never >({ namespace: MOCK_ANY_NAMESPACE }); messenger.registerActionHandler( - 'KeyringController:withKeyring', + 'KeyringController:withKeyringV2Unsafe', (selector, operation) => { if ('type' in selector) { const [id, keyring] = Object.entries(keyrings).filter( diff --git a/app/core/Snaps/permissions/utils.ts b/app/core/Snaps/permissions/utils.ts index 662d709898f..6408a103d0a 100644 --- a/app/core/Snaps/permissions/utils.ts +++ b/app/core/Snaps/permissions/utils.ts @@ -1,8 +1,6 @@ -import { - KeyringControllerWithKeyringAction, - KeyringTypes, -} from '@metamask/keyring-controller'; -import type { HdKeyring } from '@metamask/eth-hd-keyring'; +import { KeyringControllerWithKeyringV2UnsafeAction } from '@metamask/keyring-controller'; +import type { HdKeyring } from '@metamask/eth-hd-keyring/v2'; +import { KeyringType } from '@metamask/keyring-api/v2'; import { Messenger } from '@metamask/messenger'; /** @@ -14,14 +12,18 @@ import { Messenger } from '@metamask/messenger'; * @returns The mnemonic seed. */ export async function getMnemonicSeed( - messenger: Messenger, + messenger: Messenger< + string, + KeyringControllerWithKeyringV2UnsafeAction, + never + >, source?: string | undefined, ): Promise { if (!source) { const seed = (await messenger.call( - 'KeyringController:withKeyring', + 'KeyringController:withKeyringV2Unsafe', { - type: KeyringTypes.hd, + type: KeyringType.Hd, index: 0, }, async ({ keyring }) => (keyring as HdKeyring).seed, @@ -36,7 +38,7 @@ export async function getMnemonicSeed( try { const keyringData = await messenger.call( - 'KeyringController:withKeyring', + 'KeyringController:withKeyringV2Unsafe', { id: source, }, @@ -48,7 +50,7 @@ export async function getMnemonicSeed( const { type, seed } = keyringData as { type: string; seed?: Uint8Array }; - if (type !== KeyringTypes.hd || !seed) { + if (type !== KeyringType.Hd || !seed) { // The keyring isn't guaranteed to have a mnemonic (e.g., // hardware wallets, which can't be used as entropy sources), // so we throw an error if it doesn't. diff --git a/package.json b/package.json index 50cc314d694..6425aa885df 100644 --- a/package.json +++ b/package.json @@ -203,7 +203,6 @@ "@metamask/messenger@^0.3.0": "^1.0.0", "@metamask/messenger": "^1.2.0", "@metamask/keyring-internal-api": "^11.0.1", - "@metamask/accounts-controller": "^38.0.0", "@metamask/keyring-api@npm:^21.3.0": "23.1.0", "@metamask/keyring-api@npm:^21.4.0": "23.1.0", "@metamask/keyring-api@npm:^21.6.0": "23.1.0", @@ -213,7 +212,8 @@ "@metamask/permission-controller": "^13.1.1", "react-native-ble-plx@npm:3.4.0": "patch:react-native-ble-plx@npm%3A3.4.0#~/.yarn/patches/react-native-ble-plx-npm-3.4.0-401e8b3343.patch", "@metamask/transaction-controller": "67.0.0", - "@metamask/keyring-controller": "^26.0.0" + "@metamask/keyring-controller": "^26.0.0", + "@metamask/accounts-controller": "^39.0.0" }, "dependencies": { "@braze/react-native-sdk": "patch:@braze/react-native-sdk@npm%3A19.1.0#~/.yarn/patches/@braze-react-native-sdk-npm-19.1.0-076-reactmoduleinfo.patch", @@ -232,8 +232,8 @@ "@ledgerhq/react-native-hw-transport-ble": "^6.37.0", "@metamask/abi-utils": "^3.0.0", "@metamask/account-api": "^1.0.4", - "@metamask/account-tree-controller": "^7.2.0", - "@metamask/accounts-controller": "^38.0.0", + "@metamask/account-tree-controller": "^7.5.0", + "@metamask/accounts-controller": "^39.0.0", "@metamask/address-book-controller": "^7.1.2", "@metamask/ai-controllers": "^0.7.0", "@metamask/analytics-controller": "^1.0.0", @@ -285,9 +285,8 @@ "@metamask/keyring-api": "^23.1.0", "@metamask/keyring-controller": "^26.0.0", "@metamask/keyring-internal-api": "^11.0.1", - "@metamask/keyring-sdk": "^2.0.2", + "@metamask/keyring-sdk": "^2.1.1", "@metamask/keyring-snap-client": "^9.0.2", - "@metamask/keyring-utils": "^3.2.0", "@metamask/logging-controller": "^8.0.0", "@metamask/message-signing-snap": "^1.1.2", "@metamask/messenger": "^1.2.0", @@ -297,7 +296,7 @@ "@metamask/money-account-balance-service": "^1.0.0", "@metamask/money-account-controller": "^0.2.0", "@metamask/money-account-upgrade-controller": "^2.0.0", - "@metamask/multichain-account-service": "^8.0.1", + "@metamask/multichain-account-service": "^10.0.2", "@metamask/multichain-api-client": "^0.10.1", "@metamask/multichain-api-middleware": "^3.1.1", "@metamask/multichain-network-controller": "^3.1.0", @@ -332,6 +331,7 @@ "@metamask/signature-controller": "^39.2.0", "@metamask/slip44": "^4.2.0", "@metamask/smart-transactions-controller": "^24.2.0", + "@metamask/snap-account-service": "^0.3.0", "@metamask/snaps-controllers": "^20.0.5", "@metamask/snaps-execution-environments": "^11.0.2", "@metamask/snaps-rpc-methods": "^16.0.0", diff --git a/tsconfig.json b/tsconfig.json index 3b00b1107c4..ce1292945ea 100644 --- a/tsconfig.json +++ b/tsconfig.json @@ -32,6 +32,22 @@ "@metamask/json-rpc-engine/v2": [ "node_modules/@metamask/json-rpc-engine/dist/v2/index.d.cts" ], + // TODO: Remove these once we use `Node16` module resolution. + "@metamask/keyring-api/v2": [ + "node_modules/@metamask/keyring-api/dist/v2/index.d.cts" + ], + "@metamask/keyring-sdk/v2": [ + "node_modules/@metamask/keyring-sdk/dist/v2/index.d.cts" + ], + "@metamask/eth-snap-keyring/v2": [ + "node_modules/@metamask/eth-snap-keyring/dist/v2/index.d.cts" + ], + "@metamask/eth-hd-keyring/v2": [ + "node_modules/@metamask/eth-hd-keyring/dist/v2/index.d.cts" + ], + "@metamask/eth-ledger-bridge-keyring/v2": [ + "node_modules/@metamask/eth-ledger-bridge-keyring/dist/v2/index.d.cts" + ], "@metamask/design-system-react-native/spinner": [ "node_modules/@metamask/design-system-react-native/dist/components/temp-components/Spinner/index.cjs" ], diff --git a/yarn.lock b/yarn.lock index c70660364a3..5ecffada0db 100644 --- a/yarn.lock +++ b/yarn.lock @@ -7762,7 +7762,7 @@ __metadata: languageName: node linkType: hard -"@metamask/account-tree-controller@npm:^7.2.0, @metamask/account-tree-controller@npm:^7.5.1": +"@metamask/account-tree-controller@npm:^7.2.0, @metamask/account-tree-controller@npm:^7.5.0, @metamask/account-tree-controller@npm:^7.5.1": version: 7.5.1 resolution: "@metamask/account-tree-controller@npm:7.5.1" dependencies: @@ -7787,19 +7787,20 @@ __metadata: languageName: node linkType: hard -"@metamask/accounts-controller@npm:^38.0.0": - version: 38.0.0 - resolution: "@metamask/accounts-controller@npm:38.0.0" +"@metamask/accounts-controller@npm:^39.0.0": + version: 39.0.0 + resolution: "@metamask/accounts-controller@npm:39.0.0" dependencies: "@ethereumjs/util": "npm:^9.1.0" "@metamask/base-controller": "npm:^9.1.0" "@metamask/eth-snap-keyring": "npm:^22.0.1" "@metamask/keyring-api": "npm:^23.1.0" - "@metamask/keyring-controller": "npm:^25.4.0" + "@metamask/keyring-controller": "npm:^26.0.0" "@metamask/keyring-internal-api": "npm:^11.0.1" - "@metamask/keyring-utils": "npm:^3.1.0" + "@metamask/keyring-sdk": "npm:^2.1.1" + "@metamask/keyring-utils": "npm:^3.2.1" "@metamask/messenger": "npm:^1.2.0" - "@metamask/network-controller": "npm:^30.1.0" + "@metamask/network-controller": "npm:^32.0.0" "@metamask/superstruct": "npm:^3.1.0" "@metamask/utils": "npm:^11.9.0" deepmerge: "npm:^4.2.2" @@ -7810,7 +7811,7 @@ __metadata: peerDependencies: "@metamask/providers": ^22.0.0 webextension-polyfill: ^0.10.0 || ^0.11.0 || ^0.12.0 - checksum: 10/29a03bf38972dce690c11ce21a6d68870d3a18b9548647cd3969a097ba37f64c8dea3464bb0bbe56a633de24e30cdb80890dc2cbcc4e4ddbaef28b5dae242c68 + checksum: 10/1cdaae1818af96e1cccfb95452a54a936299f6aadc1f8f39a5651dbe409eced0379bcd959c2e944caad3fbba91a3b7c7e4a954a8a1eb9804302bfa0336ad7ac6 languageName: node linkType: hard @@ -8007,19 +8008,6 @@ __metadata: languageName: node linkType: hard -"@metamask/authenticated-user-storage@npm:^1.0.1": - version: 1.0.1 - resolution: "@metamask/authenticated-user-storage@npm:1.0.1" - dependencies: - "@metamask/base-data-service": "npm:^0.1.2" - "@metamask/controller-utils": "npm:^12.0.0" - "@metamask/messenger": "npm:^1.2.0" - "@metamask/superstruct": "npm:^3.1.0" - "@metamask/utils": "npm:^11.9.0" - checksum: 10/e6ae2f9a094536e269ec73f87a9939701bf2ce9fd3bf32671da49341da8d13179d55a4e34a1c9d899e48e707c7a1d12e36faaf1f0b4e5fe82445ca43b5265fc5 - languageName: node - linkType: hard - "@metamask/authenticated-user-storage@npm:^2.0.0": version: 2.0.0 resolution: "@metamask/authenticated-user-storage@npm:2.0.0" @@ -8347,15 +8335,15 @@ __metadata: languageName: node linkType: hard -"@metamask/delegation-controller@npm:^3.0.0": - version: 3.0.0 - resolution: "@metamask/delegation-controller@npm:3.0.0" +"@metamask/delegation-controller@npm:^3.0.1": + version: 3.0.1 + resolution: "@metamask/delegation-controller@npm:3.0.1" dependencies: - "@metamask/base-controller": "npm:^9.0.1" - "@metamask/keyring-controller": "npm:^25.2.0" - "@metamask/messenger": "npm:^1.1.1" + "@metamask/base-controller": "npm:^9.1.0" + "@metamask/keyring-controller": "npm:^26.0.0" + "@metamask/messenger": "npm:^1.2.0" "@metamask/utils": "npm:^11.9.0" - checksum: 10/39805fa41088f3e816d4ef650a81f724e4b52528ade882fd3580436acccdc2da4072658abe00d33fb27d597e88f97d104e140718d7e67b132ff1b9bba0887fa4 + checksum: 10/4d0c45eeb760bb7fb452c7bc5979dc8a79a1093b9d65d02e0abd92e29dd7f6f5ea7dc8ec7a9f741f8917695982ea7f28a2c56739375f41511d116fc9148edeaf languageName: node linkType: hard @@ -8370,14 +8358,14 @@ __metadata: languageName: node linkType: hard -"@metamask/delegation-core@npm:^2.0.0": - version: 2.0.0 - resolution: "@metamask/delegation-core@npm:2.0.0" +"@metamask/delegation-core@npm:^2.2.1": + version: 2.2.1 + resolution: "@metamask/delegation-core@npm:2.2.1" dependencies: "@metamask/abi-utils": "npm:^3.0.0" "@metamask/utils": "npm:^11.4.0" "@noble/hashes": "npm:^1.8.0" - checksum: 10/b473160e4cb4a6d463c6015de6e90d057034d2e8f2905068e1f44f93c8247618c5d84a155e86dfaa125dacb040951643517b9a76961bf8d215c194dc4d1cc0ad + checksum: 10/8cc532e4a5fdc83eddb36fada6283d51629272fcce8b7c7c2d3b4addde5aff7c741365a3bcd701364460fbbcbbe94fccc0460ee3ef9af284a82f84ca708f0e06 languageName: node linkType: hard @@ -8388,10 +8376,10 @@ __metadata: languageName: node linkType: hard -"@metamask/delegation-deployments@npm:^1.0.0, @metamask/delegation-deployments@npm:^1.3.0": - version: 1.3.0 - resolution: "@metamask/delegation-deployments@npm:1.3.0" - checksum: 10/58f4aafb5f0e3cbc543811cbc0100efab4ed67b9c9794b83192962153e4edbe12fd6ab6fa7be689503309862a65eb7fde771f632893d38ab54f8171aa682b34f +"@metamask/delegation-deployments@npm:^1.0.0, @metamask/delegation-deployments@npm:^1.4.0": + version: 1.4.0 + resolution: "@metamask/delegation-deployments@npm:1.4.0" + checksum: 10/e5e7b83e27daec5b1b61482647d43d4b685954818ff02687e2dbe8169a4dfe199cc8d2ed444242b60425ac2e7d2cf2a5ca29f3e69936abe6a8391f578ec693bb languageName: node linkType: hard @@ -8710,29 +8698,6 @@ __metadata: languageName: node linkType: hard -"@metamask/eth-snap-keyring@npm:^19.0.0": - version: 19.0.0 - resolution: "@metamask/eth-snap-keyring@npm:19.0.0" - dependencies: - "@ethereumjs/tx": "npm:^5.4.0" - "@metamask/eth-sig-util": "npm:^8.2.0" - "@metamask/keyring-api": "npm:^21.4.0" - "@metamask/keyring-internal-api": "npm:^10.0.0" - "@metamask/keyring-internal-snap-client": "npm:^9.0.0" - "@metamask/keyring-snap-sdk": "npm:^7.2.0" - "@metamask/keyring-utils": "npm:^3.2.0" - "@metamask/messenger": "npm:^0.3.0" - "@metamask/superstruct": "npm:^3.1.0" - "@metamask/utils": "npm:^11.1.0" - "@types/uuid": "npm:^9.0.8" - async-mutex: "npm:^0.5.0" - uuid: "npm:^9.0.1" - peerDependencies: - "@metamask/keyring-api": ^21.4.0 - checksum: 10/6e307295cb15ab44aba4ff89fb1886ad8c0ea6636748e5d84c87250fbaff9d5a3c316e63b614d7b472e4027aad0a7912109b36981e978e978bf31deea7980726 - languageName: node - linkType: hard - "@metamask/eth-snap-keyring@npm:^22.0.1": version: 22.0.1 resolution: "@metamask/eth-snap-keyring@npm:22.0.1" @@ -8982,7 +8947,7 @@ __metadata: languageName: node linkType: hard -"@metamask/keyring-api@npm:23.1.0, @metamask/keyring-api@npm:^23.1.0": +"@metamask/keyring-api@npm:^23.1.0": version: 23.1.0 resolution: "@metamask/keyring-api@npm:23.1.0" dependencies: @@ -9041,19 +9006,6 @@ __metadata: languageName: node linkType: hard -"@metamask/keyring-internal-snap-client@npm:^9.0.0": - version: 9.0.1 - resolution: "@metamask/keyring-internal-snap-client@npm:9.0.1" - dependencies: - "@metamask/keyring-api": "npm:^22.0.0" - "@metamask/keyring-internal-api": "npm:^10.0.1" - "@metamask/keyring-snap-client": "npm:^8.2.1" - "@metamask/keyring-utils": "npm:^3.2.0" - "@metamask/messenger": "npm:^0.3.0" - checksum: 10/fb683a7826856612d2d89a8f6eb83bd14a20e27c18dddb5c50699d0fefce7475ff9e3077b65296820cf0911ee49d4daf3014932d1b1d7bfd330612229e02e6f4 - languageName: node - linkType: hard - "@metamask/keyring-sdk@npm:^2.0.2, @metamask/keyring-sdk@npm:^2.1.1": version: 2.1.1 resolution: "@metamask/keyring-sdk@npm:2.1.1" @@ -9072,22 +9024,6 @@ __metadata: languageName: node linkType: hard -"@metamask/keyring-snap-client@npm:^8.2.0, @metamask/keyring-snap-client@npm:^8.2.1": - version: 8.2.1 - resolution: "@metamask/keyring-snap-client@npm:8.2.1" - dependencies: - "@metamask/keyring-api": "npm:^22.0.0" - "@metamask/keyring-utils": "npm:^3.2.0" - "@metamask/superstruct": "npm:^3.1.0" - "@types/uuid": "npm:^9.0.8" - uuid: "npm:^9.0.1" - webextension-polyfill: "npm:^0.12.0" - peerDependencies: - "@metamask/providers": ^19.0.0 - checksum: 10/f69dcb56d3089dfd1bd902ad409c017288e31d3631a2024f42f6b6ee1b4f6e663ad4d7bdfd5d3bcf6c9827190d3bad6983c89f7c821959d305f6ba8f4aae0d7c - languageName: node - linkType: hard - "@metamask/keyring-snap-client@npm:^9.0.2": version: 9.0.2 resolution: "@metamask/keyring-snap-client@npm:9.0.2" @@ -9104,22 +9040,6 @@ __metadata: languageName: node linkType: hard -"@metamask/keyring-snap-sdk@npm:^7.2.0": - version: 7.2.0 - resolution: "@metamask/keyring-snap-sdk@npm:7.2.0" - dependencies: - "@metamask/keyring-utils": "npm:^3.2.0" - "@metamask/snaps-sdk": "npm:^10.4.0" - "@metamask/superstruct": "npm:^3.1.0" - "@metamask/utils": "npm:^11.1.0" - webextension-polyfill: "npm:^0.12.0" - peerDependencies: - "@metamask/keyring-api": ^21.4.0 - "@metamask/providers": ^19.0.0 - checksum: 10/e805566d60bef72efb7298e9ee05d23acbd6e77e2cc17d47b6e9d09854f1a029b5873754d3ab0a5631a468267c0f21607de1c2cb37f57522a62cabca34b99b4d - languageName: node - linkType: hard - "@metamask/keyring-snap-sdk@npm:^9.0.1": version: 9.0.1 resolution: "@metamask/keyring-snap-sdk@npm:9.0.1" @@ -9136,7 +9056,7 @@ __metadata: languageName: node linkType: hard -"@metamask/keyring-utils@npm:^3.1.0, @metamask/keyring-utils@npm:^3.2.0, @metamask/keyring-utils@npm:^3.2.1, @metamask/keyring-utils@npm:^3.3.1": +"@metamask/keyring-utils@npm:^3.2.0, @metamask/keyring-utils@npm:^3.2.1, @metamask/keyring-utils@npm:^3.3.1": version: 3.3.1 resolution: "@metamask/keyring-utils@npm:3.3.1" dependencies: @@ -9268,20 +9188,20 @@ __metadata: linkType: hard "@metamask/money-account-upgrade-controller@npm:^2.0.0": - version: 2.0.1 - resolution: "@metamask/money-account-upgrade-controller@npm:2.0.1" + version: 2.0.3 + resolution: "@metamask/money-account-upgrade-controller@npm:2.0.3" dependencies: - "@metamask/authenticated-user-storage": "npm:^1.0.1" + "@metamask/authenticated-user-storage": "npm:^2.0.0" "@metamask/base-controller": "npm:^9.1.0" "@metamask/chomp-api-service": "npm:^3.1.0" - "@metamask/delegation-controller": "npm:^3.0.0" - "@metamask/delegation-core": "npm:^2.0.0" - "@metamask/delegation-deployments": "npm:^1.3.0" - "@metamask/keyring-controller": "npm:^25.5.0" + "@metamask/delegation-controller": "npm:^3.0.1" + "@metamask/delegation-core": "npm:^2.2.1" + "@metamask/delegation-deployments": "npm:^1.4.0" + "@metamask/keyring-controller": "npm:^26.0.0" "@metamask/messenger": "npm:^1.2.0" "@metamask/network-controller": "npm:^32.0.0" "@metamask/utils": "npm:^11.9.0" - checksum: 10/5a903ccde41188f9e61a0200f79b8bae540c0ca31d8b7e4089708c876eaba46accdd7b2b5e110e7228d724b35e8f279b2fc5e257f3dc9d5d0f2b764416f968fe + checksum: 10/6ae13e6a2232bd77976228691ea13762cb6eaa18358e7d3446e5db6942734cffdc606c09e55bb989aab5049148c49a6e3a7bad34c5a91644c60e0fe93db8989c languageName: node linkType: hard @@ -9316,36 +9236,6 @@ __metadata: languageName: node linkType: hard -"@metamask/multichain-account-service@npm:^8.0.1": - version: 8.0.1 - resolution: "@metamask/multichain-account-service@npm:8.0.1" - dependencies: - "@ethereumjs/util": "npm:^9.1.0" - "@metamask/accounts-controller": "npm:^37.1.1" - "@metamask/base-controller": "npm:^9.0.1" - "@metamask/eth-snap-keyring": "npm:^19.0.0" - "@metamask/key-tree": "npm:^10.1.1" - "@metamask/keyring-api": "npm:^21.6.0" - "@metamask/keyring-controller": "npm:^25.1.1" - "@metamask/keyring-internal-api": "npm:^10.0.0" - "@metamask/keyring-snap-client": "npm:^8.2.0" - "@metamask/keyring-utils": "npm:^3.1.0" - "@metamask/messenger": "npm:^1.0.0" - "@metamask/snaps-controllers": "npm:^19.0.0" - "@metamask/snaps-sdk": "npm:^11.0.0" - "@metamask/snaps-utils": "npm:^12.1.2" - "@metamask/superstruct": "npm:^3.1.0" - "@metamask/utils": "npm:^11.9.0" - async-mutex: "npm:^0.5.0" - lodash: "npm:^4.17.21" - peerDependencies: - "@metamask/account-api": ^1.0.0 - "@metamask/providers": ^22.0.0 - webextension-polyfill: ^0.10.0 || ^0.11.0 || ^0.12.0 - checksum: 10/7ac3c38db8afd47593cd7ed4cc95de99195ccd2b2903281382be83990b2a47fa2f03de77e325c1ea3c7fd96367e3eac20039994d95154d478bebacd61215140a - languageName: node - linkType: hard - "@metamask/multichain-api-client@npm:^0.10.1": version: 0.10.1 resolution: "@metamask/multichain-api-client@npm:0.10.1" @@ -10464,7 +10354,7 @@ __metadata: languageName: node linkType: hard -"@metamask/utils@npm:^11.0.0, @metamask/utils@npm:^11.0.1, @metamask/utils@npm:^11.1.0, @metamask/utils@npm:^11.10.0, @metamask/utils@npm:^11.11.0, @metamask/utils@npm:^11.4.0, @metamask/utils@npm:^11.4.2, @metamask/utils@npm:^11.5.0, @metamask/utils@npm:^11.8.1, @metamask/utils@npm:^11.9.0": +"@metamask/utils@npm:^11.0.0, @metamask/utils@npm:^11.0.1, @metamask/utils@npm:^11.10.0, @metamask/utils@npm:^11.11.0, @metamask/utils@npm:^11.4.0, @metamask/utils@npm:^11.4.2, @metamask/utils@npm:^11.5.0, @metamask/utils@npm:^11.8.1, @metamask/utils@npm:^11.9.0": version: 11.11.0 resolution: "@metamask/utils@npm:11.11.0" dependencies: @@ -35327,8 +35217,8 @@ __metadata: "@ledgerhq/react-native-hw-transport-ble": "npm:^6.37.0" "@metamask/abi-utils": "npm:^3.0.0" "@metamask/account-api": "npm:^1.0.4" - "@metamask/account-tree-controller": "npm:^7.2.0" - "@metamask/accounts-controller": "npm:^38.0.0" + "@metamask/account-tree-controller": "npm:^7.5.0" + "@metamask/accounts-controller": "npm:^39.0.0" "@metamask/address-book-controller": "npm:^7.1.2" "@metamask/ai-controllers": "npm:^0.7.0" "@metamask/analytics-controller": "npm:^1.0.0" @@ -35387,9 +35277,8 @@ __metadata: "@metamask/keyring-api": "npm:^23.1.0" "@metamask/keyring-controller": "npm:^26.0.0" "@metamask/keyring-internal-api": "npm:^11.0.1" - "@metamask/keyring-sdk": "npm:^2.0.2" + "@metamask/keyring-sdk": "npm:^2.1.1" "@metamask/keyring-snap-client": "npm:^9.0.2" - "@metamask/keyring-utils": "npm:^3.2.0" "@metamask/logging-controller": "npm:^8.0.0" "@metamask/message-signing-snap": "npm:^1.1.2" "@metamask/messenger": "npm:^1.2.0" @@ -35400,7 +35289,7 @@ __metadata: "@metamask/money-account-balance-service": "npm:^1.0.0" "@metamask/money-account-controller": "npm:^0.2.0" "@metamask/money-account-upgrade-controller": "npm:^2.0.0" - "@metamask/multichain-account-service": "npm:^8.0.1" + "@metamask/multichain-account-service": "npm:^10.0.2" "@metamask/multichain-api-client": "npm:^0.10.1" "@metamask/multichain-api-middleware": "npm:^3.1.1" "@metamask/multichain-network-controller": "npm:^3.1.0" @@ -35438,6 +35327,7 @@ __metadata: "@metamask/skills": "npm:^0.1.0" "@metamask/slip44": "npm:^4.2.0" "@metamask/smart-transactions-controller": "npm:^24.2.0" + "@metamask/snap-account-service": "npm:^0.3.0" "@metamask/snaps-controllers": "npm:^20.0.5" "@metamask/snaps-execution-environments": "npm:^11.0.2" "@metamask/snaps-rpc-methods": "npm:^16.0.0" From dab94c23681e60c53104375eef5c956525fc34c1 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Jo=C3=A3o=20Santos?= Date: Wed, 10 Jun 2026 14:07:18 +0200 Subject: [PATCH 02/15] fix(quickbuy): spend exact balance when selling all to avoid false insufficient funds (#31450) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ## **Description** When selling the **entire** balance of a near-$1 source token (e.g. mUSD) via QuickBuy's "Sell" flow, the trade was incorrectly blocked with an **"Insufficient funds"** button state. **Root cause:** the QuickBuy "Sell" flow is fiat-first. When the slider is dragged to 100%, the committed amount was reconstructed through a lossy fiat round-trip: `balance → USD (rounded to 2 decimals via .toFixed(2)) → tokens (usd / currencyExchangeRate)`. Because the exchange rate of these tokens is fractionally below `1`, dividing the cent-rounded USD value back into tokens yields an amount **slightly larger** than the actual on-chain balance (e.g. `0.10003 MUSD` balance → `$0.10` → `0.10003001… MUSD`). `useIsInsufficientBalance` then compared this inflated amount against the true atomic balance and flagged the trade. The unpriced slider path had the same class of issue via `parseFloat` reconstruction. **Fix:** an `isMaxSourceAmount` sentinel. When the slider is committed at 100% ("sell all"), `sourceTokenAmount` now returns the exact `latestSourceBalance.displayBalance` (which is `formatUnits(atomicBalance, decimals)` and therefore round-trips back to the precise atomic balance) instead of the reconstructed value. The flag is set at the priced commit (`handleSliderDragEnd`) and the unpriced commit (`handleSliderChange`), and cleared on custom typed input and on amount reset (token switch / mode flip). This fixes both the priced (reported mUSD) and unpriced paths. ## **Changelog** CHANGELOG entry: Fixed a bug where selling your entire balance of a token in QuickBuy could be incorrectly blocked with an "Insufficient funds" error. ## **Related issues** Fixes: TSA-650 ## **Manual testing steps** ```gherkin Feature: QuickBuy sell-all does not falsely report insufficient funds Scenario: Selling the entire balance of a near-$1 token Given I hold a small balance of a near-$1 token (e.g. mUSD) whose exchange rate is fractionally below 1 And I open the QuickBuy sheet and select the "Sell" tab And I have selected a token to receive (e.g. ETH) When I drag the amount slider all the way to 100% (sell all) Then the headline shows my full balance and its fiat value And the confirm button is enabled (NOT "Insufficient funds") And submitting the trade sells the exact on-chain balance Scenario: Selling a partial amount still works When I drag the slider to a value below 100% Then the amount is derived from the fiat value as before And the confirm button reflects the correct sufficient/insufficient state ``` ## **Screenshots/Recordings** ### **Before** ### **After** N/A — logic-only fix covered by unit tests; no visual change beyond the button no longer being erroneously disabled. ## **Pre-merge author checklist** - [x] I've followed [MetaMask Contributor Docs](https://github.com/MetaMask/contributor-docs) and [MetaMask Mobile Coding Standards](https://github.com/MetaMask/metamask-mobile/blob/main/.github/guidelines/CODING_GUIDELINES.md). - [x] I've completed the PR template to the best of my ability - [x] I've included tests if applicable - [x] I've documented my code using [JSDoc](https://jsdoc.app/) format if applicable - [x] I've applied the right labels on the PR (see [labeling guidelines](https://github.com/MetaMask/metamask-mobile/blob/main/.github/guidelines/LABELING_GUIDELINES.md)). Not required for external contributors. #### Performance checks (if applicable) - [x] I've tested on Android - Ideally on a mid-range device; emulator is acceptable - [x] I've tested with a power user scenario - Use these [power-user SRPs](https://consensyssoftware.atlassian.net/wiki/spaces/TL1/pages/edit-v2/401401446401?draftShareId=9d77e1e1-4bdc-4be1-9ebb-ccd916988d93) to import wallets with many accounts and tokens - [x] I've instrumented key operations with Sentry traces for production performance metrics - See [`trace()`](/app/util/trace.ts) for usage and [`addToken`](/app/components/Views/AddAsset/components/AddCustomToken/AddCustomToken.tsx#L274) for an example For performance guidelines and tooling, see the [Performance Guide](https://consensyssoftware.atlassian.net/wiki/spaces/TL1/pages/400085549067/Performance+Guide+for+Engineers). ## **Pre-merge reviewer checklist** - [ ] I've manually tested the PR (e.g. pull and build branch, run the app, test code being changed). - [ ] I confirm that this PR addresses all acceptance criteria described in the ticket it closes and includes the necessary testing evidence such as recordings and or screenshots. --- > [!NOTE] > **Low Risk** > Localized QuickBuy amount derivation and balance checks; no auth or payment-rail changes, with new unit tests covering the regression. > > **Overview** > Fixes QuickBuy **sell-all** falsely showing **Insufficient funds** when the slider is committed at **100%**. > > The change adds an **`isMaxSourceAmount`** flag so **`sourceTokenAmount`** uses **`latestSourceBalance.displayBalance`** (exact on-chain balance) instead of amounts rebuilt from **cent-rounded USD** or **float slider math**, which could exceed the real balance for near-$1 tokens. The flag is set on **priced** slider commit (`handleSliderDragEnd`, including **before** the USD dedup guard so typed-max then slide-to-100% still works) and on the **unpriced** path (`handleSliderChange`), and cleared on **reset**, **custom input**, and non-100% slider moves. **`useLatestBalance`** is moved earlier so max mode can use it in the memo. **Four unit tests** cover the stablecoin round-trip scenario. > > Reviewed by [Cursor Bugbot](https://cursor.com/bugbot) for commit 903b931c29485795d787a7943f3104da8740d93f. Bugbot is set up for automated code reviews on this repo. Configure [here](https://www.cursor.com/dashboard/bugbot). --------- Co-authored-by: Claude Opus 4.8 (1M context) --- .../QuickBuy/hooks/useQuickBuyController.ts | 45 ++++++++-- .../QuickBuy/useQuickBuyController.test.ts | 87 +++++++++++++++++++ 2 files changed, 125 insertions(+), 7 deletions(-) diff --git a/app/components/Views/SocialLeaderboard/TraderPositionView/components/QuickBuy/hooks/useQuickBuyController.ts b/app/components/Views/SocialLeaderboard/TraderPositionView/components/QuickBuy/hooks/useQuickBuyController.ts index 7ab536f8780..e8aedb2dd02 100644 --- a/app/components/Views/SocialLeaderboard/TraderPositionView/components/QuickBuy/hooks/useQuickBuyController.ts +++ b/app/components/Views/SocialLeaderboard/TraderPositionView/components/QuickBuy/hooks/useQuickBuyController.ts @@ -231,6 +231,12 @@ export function useQuickBuyController( const [tradeMode, setTradeMode] = useState('buy'); const [usdAmount, setUsdAmount] = useState(''); const [sourceAmountTokens, setSourceAmountTokens] = useState(''); + // True when the user has committed the slider to 100% ("sell all"). In this + // mode `sourceTokenAmount` spends the exact on-chain balance rather than a + // value reconstructed from the fiat round-trip / float math, either of which + // can land just above the real balance and falsely trip the insufficient- + // funds gate. Reset on any other amount input. + const [isMaxSourceAmount, setIsMaxSourceAmount] = useState(false); // Drives quote re-fetching. Updated only when the user commits a value // (slider drag end, tap, or text input) — NOT on every drag tick. This // prevents spamming quote requests while the thumb is moving. @@ -403,7 +409,22 @@ export function useQuickBuyController( sourceToken?.currencyExchangeRate && sourceToken.currencyExchangeRate > 0, ); + const latestSourceBalance = useLatestBalance({ + address: sourceToken?.address, + decimals: sourceToken?.decimals, + chainId: sourceToken?.chainId, + balance: sourceToken?.balance, + }); + const sourceTokenAmount = useMemo(() => { + // Max ("sell all"): spend the exact on-chain balance. `displayBalance` is + // `formatUnits(atomicBalance)`, so it round-trips back to the precise + // atomic balance — unlike the fiat (priced) or float (unpriced) paths + // below, which can reconstruct a value fractionally above the real balance + // and falsely block the trade with "Insufficient funds". + if (isMaxSourceAmount && latestSourceBalance?.displayBalance) { + return latestSourceBalance.displayBalance; + } if (hasSourcePrice) { if (!quotedUsdAmount || !sourceToken?.currencyExchangeRate) { return undefined; @@ -419,6 +440,8 @@ export function useQuickBuyController( return sourceAmountTokens; }, [ hasSourcePrice, + isMaxSourceAmount, + latestSourceBalance?.displayBalance, quotedUsdAmount, sourceAmountTokens, sourceToken?.currencyExchangeRate, @@ -432,13 +455,6 @@ export function useQuickBuyController( } }, [sourceTokenAmount, dispatch]); - const latestSourceBalance = useLatestBalance({ - address: sourceToken?.address, - decimals: sourceToken?.decimals, - chainId: sourceToken?.chainId, - balance: sourceToken?.balance, - }); - // Used for analytics passed to useQuickBuyQuotes. Must derive from // quotedUsdAmount (not usdAmount) so that mid-drag display updates don't // recreate quotesAnalyticsContext and trigger spurious quote re-fetches. @@ -697,11 +713,15 @@ export function useQuickBuyController( setSliderPercent(rounded); // ── Unpriced path: drive token-amount state directly. ─────────────── + // This branch commits on every tick (no separate drag-end commit), so the + // max sentinel is set here rather than in handleSliderDragEnd. if (!hasSourcePrice) { if (maxSpendTokens <= 0) { setSourceAmountTokens(''); + setIsMaxSourceAmount(false); return; } + setIsMaxSourceAmount(rounded >= 100); const nextTokens = rounded === 0 ? '' : ((maxSpendTokens * rounded) / 100).toString(); setSourceAmountTokens(nextTokens); @@ -733,6 +753,15 @@ export function useQuickBuyController( ? '' : ((maxSpendUsd * rounded) / 100).toFixed(2); + // Flag max BEFORE the dedup guard. lastCommittedUsdRef is also written by + // typed input and the price-migration effect, so releasing the slider at + // 100% can match the ref and return early (e.g. user typed the exact max, + // then slid to 100% to "sell all"). If setIsMaxSourceAmount ran after the + // guard, sourceTokenAmount would fall back to the cent-rounded fiat + // reconstruction and falsely trip insufficient-funds on sell-all. Setting + // it here is idempotent, so the dedup path is unaffected. + setIsMaxSourceAmount(rounded >= 100); + // Deduplicate: Tap + Pan can both fire onEnd for a pure tap gesture. if (nextUsd === lastCommittedUsdRef.current) { return; @@ -792,6 +821,7 @@ export function useQuickBuyController( setUsdAmount(''); setQuotedUsdAmount(''); setSourceAmountTokens(''); + setIsMaxSourceAmount(false); setSliderPercent(0); lastSliderPercentRef.current = 0; lastCommittedUsdRef.current = ''; @@ -882,6 +912,7 @@ export function useQuickBuyController( } lastSliderPercentRef.current = 0; setSliderPercent(0); + setIsMaxSourceAmount(false); }, [hasSourcePrice, sourceToken?.decimals, lastInputMethodRef], ); diff --git a/app/components/Views/SocialLeaderboard/TraderPositionView/components/QuickBuy/useQuickBuyController.test.ts b/app/components/Views/SocialLeaderboard/TraderPositionView/components/QuickBuy/useQuickBuyController.test.ts index 13821e0574d..0013c545b50 100644 --- a/app/components/Views/SocialLeaderboard/TraderPositionView/components/QuickBuy/useQuickBuyController.test.ts +++ b/app/components/Views/SocialLeaderboard/TraderPositionView/components/QuickBuy/useQuickBuyController.test.ts @@ -587,6 +587,93 @@ describe('useQuickBuyController', () => { }); }); + describe('max ("sell all") source amount', () => { + // A near-$1 stablecoin whose rate is fractionally below 1. The fiat + // round-trip (balance → cent-rounded USD → tokens) reconstructs an amount + // slightly ABOVE the real balance, which previously tripped the + // insufficient-funds gate when selling the entire balance. + const NEAR_DOLLAR_RATE = 0.9997; + const FULL_BALANCE = '0.10003'; + + const renderWithStablecoin = () => { + (useLatestBalance as jest.Mock).mockReturnValue({ + displayBalance: FULL_BALANCE, + atomicBalance: '100030000000000000', + }); + const stablecoin = createSourceToken({ + symbol: 'MUSD', + currencyExchangeRate: NEAR_DOLLAR_RATE, + balance: FULL_BALANCE, + }); + (usePayWithTokens as jest.Mock).mockReturnValue({ + options: [stablecoin], + }); + return renderHook(() => useQuickBuyController(createTarget(), jest.fn())); + }; + + it('spends the exact on-chain balance instead of the fiat round-trip at 100%', () => { + const { result } = renderWithStablecoin(); + + act(() => { + result.current.handleSliderChange(100); + }); + act(() => { + result.current.handleSliderDragEnd(100); + }); + + // Exact balance — not `usd / rate`, which would exceed FULL_BALANCE. + expect(result.current.sourceTokenAmount).toBe(FULL_BALANCE); + }); + + it('passes the exact balance to the insufficient-balance check at 100%', () => { + const { result } = renderWithStablecoin(); + + act(() => { + result.current.handleSliderChange(100); + }); + act(() => { + result.current.handleSliderDragEnd(100); + }); + + expect(useIsInsufficientBalance).toHaveBeenLastCalledWith( + expect.objectContaining({ amount: FULL_BALANCE }), + ); + }); + + it('still derives the amount from fiat below 100%', () => { + const { result } = renderWithStablecoin(); + + act(() => { + result.current.handleSliderChange(50); + }); + act(() => { + result.current.handleSliderDragEnd(50); + }); + + expect(result.current.sourceTokenAmount).not.toBe(FULL_BALANCE); + expect(Number(result.current.sourceTokenAmount)).toBeCloseTo(0.05, 2); + }); + + it('clears the max flag once the user types a custom amount', () => { + const { result } = renderWithStablecoin(); + + act(() => { + result.current.handleSliderChange(100); + }); + act(() => { + result.current.handleSliderDragEnd(100); + }); + expect(result.current.sourceTokenAmount).toBe(FULL_BALANCE); + + act(() => { + result.current.handleAmountChange('0.05'); + }); + + expect(result.current.sourceTokenAmount).not.toBe(FULL_BALANCE); + expect(Number(result.current.sourceTokenAmount)).toBeCloseTo(0.05, 2); + }); + }); + describe('handleSelectSourceToken', () => { it('updates the selected token and resets amount + slider state', () => { (useLatestBalance as jest.Mock).mockReturnValue({ From c6cc33f4efb25425a88334f4ef3dd63bcdf73b8b Mon Sep 17 00:00:00 2001 From: Kirill Zyusko Date: Wed, 10 Jun 2026 14:20:02 +0200 Subject: [PATCH 03/15] feat: `Skeleton` migration (`predict` team) (#29052) ## **Description** Migrate `Skeleton` component (predict team). ## **Changelog** CHANGELOG entry: null ## **Related issues** Fixes: https://consensyssoftware.atlassian.net/browse/DSYS-465 ## **Manual testing steps** ```gherkin Feature: skeleton migration Scenario: user checks prediction Given the user on prediction page When user clicks yes for a prediction Then skeleton should be visible ``` ## **Screenshots/Recordings** ### **Before** image ### **After** image ## **Pre-merge author checklist** - [x] I've followed [MetaMask Contributor Docs](https://github.com/MetaMask/contributor-docs) and [MetaMask Mobile Coding Standards](https://github.com/MetaMask/metamask-mobile/blob/main/.github/guidelines/CODING_GUIDELINES.md). - [x] I've completed the PR template to the best of my ability - [x] I've included tests if applicable - [x] I've documented my code using [JSDoc](https://jsdoc.app/) format if applicable - [x] I've applied the right labels on the PR (see [labeling guidelines](https://github.com/MetaMask/metamask-mobile/blob/main/.github/guidelines/LABELING_GUIDELINES.md)). Not required for external contributors. #### Performance checks (if applicable) - [x] I've tested on Android - Ideally on a mid-range device; emulator is acceptable - [x] I've tested with a power user scenario - Use these [power-user SRPs](https://consensyssoftware.atlassian.net/wiki/spaces/TL1/pages/edit-v2/401401446401?draftShareId=9d77e1e1-4bdc-4be1-9ebb-ccd916988d93) to import wallets with many accounts and tokens - [x] I've instrumented key operations with Sentry traces for production performance metrics - See [`trace()`](/app/util/trace.ts) for usage and [`addToken`](/app/components/Views/AddAsset/components/AddCustomToken/AddCustomToken.tsx#L274) for an example For performance guidelines and tooling, see the [Performance Guide](https://consensyssoftware.atlassian.net/wiki/spaces/TL1/pages/400085549067/Performance+Guide+for+Engineers). ## **Pre-merge reviewer checklist** - [ ] I've manually tested the PR (e.g. pull and build branch, run the app, test code being changed). - [ ] I confirm that this PR addresses all acceptance criteria described in the ticket it closes and includes the necessary testing evidence such as recordings and or screenshots. --- > [!NOTE] > **Low Risk** > Import-only design-system path updates with no behavior changes; low regression risk beyond loading UI appearance if the temp Skeleton API differs. > > **Overview** > Updates Predict buy flow UI to import **Skeleton** from `component-library/components-temp/Skeleton` instead of the legacy `components/Skeleton` path. > > **PredictBuyAmountSection** and **PredictFeeSummary** keep the same loading placeholders; only the module path changes. The **PredictBuyAmountSection** test mock is updated to match the new import. > > Reviewed by [Cursor Bugbot](https://cursor.com/bugbot) for commit 20d9b1d0ac48cc8c8668da908f479ab3f894a61b. Bugbot is set up for automated code reviews on this repo. Configure [here](https://www.cursor.com/dashboard/bugbot). --- .../PredictBuyAmountSection/PredictBuyAmountSection.test.tsx | 2 +- .../PredictBuyAmountSection/PredictBuyAmountSection.tsx | 2 +- .../components/PredictFeeSummary/PredictFeeSummary.tsx | 2 +- 3 files changed, 3 insertions(+), 3 deletions(-) diff --git a/app/components/UI/Predict/views/PredictBuyWithAnyToken/components/PredictBuyAmountSection/PredictBuyAmountSection.test.tsx b/app/components/UI/Predict/views/PredictBuyWithAnyToken/components/PredictBuyAmountSection/PredictBuyAmountSection.test.tsx index fdadcfe21e0..0754b5bd8fc 100644 --- a/app/components/UI/Predict/views/PredictBuyWithAnyToken/components/PredictBuyAmountSection/PredictBuyAmountSection.test.tsx +++ b/app/components/UI/Predict/views/PredictBuyWithAnyToken/components/PredictBuyAmountSection/PredictBuyAmountSection.test.tsx @@ -43,7 +43,7 @@ jest.mock('../../../../components/PredictAmountDisplay', () => { }); jest.mock( - '../../../../../../../component-library/components/Skeleton/Skeleton', + '../../../../../../../component-library/components-temp/Skeleton/Skeleton', () => { const { View: RNView } = jest.requireActual('react-native'); return function MockSkeleton(props: Record) { diff --git a/app/components/UI/Predict/views/PredictBuyWithAnyToken/components/PredictBuyAmountSection/PredictBuyAmountSection.tsx b/app/components/UI/Predict/views/PredictBuyWithAnyToken/components/PredictBuyAmountSection/PredictBuyAmountSection.tsx index 9e05d3b6ad1..1f38638fc07 100644 --- a/app/components/UI/Predict/views/PredictBuyWithAnyToken/components/PredictBuyAmountSection/PredictBuyAmountSection.tsx +++ b/app/components/UI/Predict/views/PredictBuyWithAnyToken/components/PredictBuyAmountSection/PredictBuyAmountSection.tsx @@ -11,7 +11,7 @@ import { useTailwind } from '@metamask/design-system-twrnc-preset'; import React, { useEffect, useRef } from 'react'; import { Animated } from 'react-native'; import { strings } from '../../../../../../../../locales/i18n'; -import Skeleton from '../../../../../../../component-library/components/Skeleton/Skeleton'; +import Skeleton from '../../../../../../../component-library/components-temp/Skeleton/Skeleton'; import { formatPrice } from '../../../../utils/format'; import PredictAmountDisplay from '../../../../components/PredictAmountDisplay'; import type { PredictKeypadHandles } from '../../../../components/PredictKeypad'; diff --git a/app/components/UI/Predict/views/PredictBuyWithAnyToken/components/PredictFeeSummary/PredictFeeSummary.tsx b/app/components/UI/Predict/views/PredictBuyWithAnyToken/components/PredictFeeSummary/PredictFeeSummary.tsx index d50b6bc4824..ae134843752 100644 --- a/app/components/UI/Predict/views/PredictBuyWithAnyToken/components/PredictFeeSummary/PredictFeeSummary.tsx +++ b/app/components/UI/Predict/views/PredictBuyWithAnyToken/components/PredictFeeSummary/PredictFeeSummary.tsx @@ -19,7 +19,7 @@ import { strings } from '../../../../../../../../locales/i18n'; import KeyValueRow from '../../../../../../../component-library/components-temp/KeyValueRow'; import { TooltipSizes } from '../../../../../../../component-library/components-temp/KeyValueRow/KeyValueRow.types'; import { IconName as IconNameLegacy } from '../../../../../../../component-library/components/Icons/Icon'; -import Skeleton from '../../../../../../../component-library/components/Skeleton/Skeleton'; +import Skeleton from '../../../../../../../component-library/components-temp/Skeleton/Skeleton'; import { TextColor as LegacyTextColor, TextVariant as LegacyTextVariant, From 737bdf7437c21ceec1dfd2fac637df474d5ff31a Mon Sep 17 00:00:00 2001 From: Matthew Grainger <46547583+Matt561@users.noreply.github.com> Date: Wed, 10 Jun 2026 09:07:44 -0400 Subject: [PATCH 04/15] feat: MUSD-946 disable send button when user has zero balance (#31440) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ## **Description** The transfer button on the Money home view used to be tappable even when the account had no spendable balance, letting people start a transfer they couldn't complete. It's now disabled until there's a real, spendable balance. "Spendable" means a balance value is loaded and is at least one cent — sub-cent dust no longer counts as funded, since mUSD is USD-pegged and anything below a cent is economically meaningless. This same threshold now drives the funded/unfunded state, so the earnings, how-it-works, and what-you-get sections stay consistent with the button and with the Money balance displayed to the user. `MoneyActionButtonRow` was reworked to take per-button config objects (`add`/`transfer`/`card`) so individual buttons can be disabled, and the now-unused `isAccountFunded` util was removed. ## **Changelog** CHANGELOG entry: disable the transfer button on the Money Home screen when Money account does not have spendable balance. ## **Related issues** Fixes: [MUSD-946](https://consensyssoftware.atlassian.net/browse/MUSD-946) ## **Manual testing steps** ```gherkin Feature: Money transfer button availability Scenario: user with an empty balance cannot transfer Given a Money account with a zero or sub-cent balance When user opens the Money home view Then the transfer button is disabled Scenario: user with a funded balance can transfer Given a Money account with a balance of at least one cent When user opens the Money home view Then the transfer button is enabled And user can start a transfer ``` ## **Screenshots/Recordings** ### **Before** N/A - Transfer button was enabled ### **After** https://github.com/user-attachments/assets/2c0a9534-a701-4ab1-af06-ad7204a5f6cc ## **Pre-merge author checklist** - [x] I've followed [MetaMask Contributor Docs](https://github.com/MetaMask/contributor-docs) and [MetaMask Mobile Coding Standards](https://github.com/MetaMask/metamask-mobile/blob/main/.github/guidelines/CODING_GUIDELINES.md). - [x] I've completed the PR template to the best of my ability - [x] I've included tests if applicable - [x] I've documented my code using [JSDoc](https://jsdoc.app/) format if applicable - [x] I've applied the right labels on the PR (see [labeling guidelines](https://github.com/MetaMask/metamask-mobile/blob/main/.github/guidelines/LABELING_GUIDELINES.md)). Not required for external contributors. #### Performance checks (if applicable) - [x] I've tested on Android - Ideally on a mid-range device; emulator is acceptable - [x] I've tested with a power user scenario - Use these [power-user SRPs](https://consensyssoftware.atlassian.net/wiki/spaces/TL1/pages/edit-v2/401401446401?draftShareId=9d77e1e1-4bdc-4be1-9ebb-ccd916988d93) to import wallets with many accounts and tokens - [x] I've instrumented key operations with Sentry traces for production performance metrics - See [`trace()`](/app/util/trace.ts) for usage and [`addToken`](/app/components/Views/AddAsset/components/AddCustomToken/AddCustomToken.tsx#L274) for an example For performance guidelines and tooling, see the [Performance Guide](https://consensyssoftware.atlassian.net/wiki/spaces/TL1/pages/400085549067/Performance+Guide+for+Engineers). ## **Pre-merge reviewer checklist** - [ ] I've manually tested the PR (e.g. pull and build branch, run the app, test code being changed). - [ ] I confirm that this PR addresses all acceptance criteria described in the ticket it closes and includes the necessary testing evidence such as recordings and or screenshots. [MUSD-946]: https://consensyssoftware.atlassian.net/browse/MUSD-946?atlOrigin=eyJpIjoiNWRkNTljNzYxNjVmNDY3MDlhMDU5Y2ZhYzA5YTRkZjUiLCJwIjoiZ2l0aHViLWNvbS1KU1cifQ --- > [!NOTE] > **Medium Risk** > Changes Money home funded/unfunded gating and transfer entry across several balance edge cases; behavior is well covered by new tests but affects a primary user flow. > > **Overview** > Disables **Transfer** on Money Home until the account has a **spendable** balance: loaded fiat raw amount at least **one cent** (`DUST_THRESHOLD`), replacing `tokenTotal` / `isAccountFunded` with `hasSpendableBalance` and `hasBalanceValue` for funded vs unfunded UI. > > **Transfer** is wired through `MoneyActionButtonRow` with per-button `{ onPress, disabled? }` configs and `ButtonBase` `isDisabled`, so zero, loading, unavailable-rate, and sub-cent dust balances cannot open the transfer sheet. **MoneyEarnings** and unfunded onboarding (**How it works**, **What you get**) follow the same rules; unavailable balance no longer shows empty-account onboarding. > > Exports `DUST_THRESHOLD` from `moneyFormatFiat` and removes `isAccountFunded`. Tests cover dust, unavailable, disabled transfer, and regression cases. > > Reviewed by [Cursor Bugbot](https://cursor.com/bugbot) for commit d4df2d0ffc3dd0cda8bf6a9fa7664d7dbd1b373c. Bugbot is set up for automated code reviews on this repo. Configure [here](https://www.cursor.com/dashboard/bugbot). --- .../MoneyHomeView/MoneyHomeView.test.tsx | 207 ++++++++++++++++-- .../Views/MoneyHomeView/MoneyHomeView.tsx | 70 +++--- .../MoneyActionButtonRow.test.tsx | 77 +++++-- .../MoneyActionButtonRow.tsx | 88 ++++---- .../UI/Money/utils/isAccountFunded.test.ts | 21 -- .../UI/Money/utils/isAccountFunded.ts | 4 - .../UI/Money/utils/moneyFormatFiat.ts | 6 +- 7 files changed, 331 insertions(+), 142 deletions(-) delete mode 100644 app/components/UI/Money/utils/isAccountFunded.test.ts delete mode 100644 app/components/UI/Money/utils/isAccountFunded.ts diff --git a/app/components/UI/Money/Views/MoneyHomeView/MoneyHomeView.test.tsx b/app/components/UI/Money/Views/MoneyHomeView/MoneyHomeView.test.tsx index 34bcfa2697a..3d15475cc93 100644 --- a/app/components/UI/Money/Views/MoneyHomeView/MoneyHomeView.test.tsx +++ b/app/components/UI/Money/Views/MoneyHomeView/MoneyHomeView.test.tsx @@ -118,6 +118,7 @@ jest.mock('../../../../../core/NavigationService', () => ({ })); jest.mock('../../utils/moneyFormatFiat', () => ({ + ...jest.requireActual('../../utils/moneyFormatFiat'), moneyFormatFiat: jest.fn(() => '$0.12'), })); @@ -584,7 +585,126 @@ describe('MoneyHomeView', () => { }); }); - it('MoneyHowItWorks stays mounted in noAccount state when balance is unfunded', () => { + describe('dust threshold gating', () => { + const dustMock = { + totalFiatFormatted: '$0.00', + musdFiatFormatted: '$0.00', + musdSHFvdFiatFormatted: '$0.00', + // 0.0001 < DUST_THRESHOLD (0.01) — displays as $0.00 but is above zero + totalFiatRaw: '0.0001', + withdrawableMusd: undefined, + isAggregatedBalanceLoading: false, + isBalanceFetchError: false, + isBalanceFetching: false, + refetchBalance: jest.fn(), + apyDecimal: 0.05, + apyPercent: 5, + apyPercentFormatted: '5%', + vaultApyQuery: { data: { apy: 0.05 }, isLoading: false }, + musdBalanceQuery: { data: undefined, isLoading: false }, + musdEquivalentBalanceQuery: { data: undefined, isLoading: false }, + } as unknown as ReturnType; + + beforeEach(() => { + mockUseMoneyAccountBalance.mockReturnValue(dustMock); + mockUseMoneyAccountTransactions.mockReturnValue({ + allTransactions: [], + deposits: [], + transfers: [], + submittedTransactions: [], + moneyAddress: '0x0000000000000000000000000000000000000001', + mockDataEnabled: false, + }); + }); + + it('disables Transfer button for sub-cent (dust) balance', () => { + const { getByTestId } = renderWithProvider(); + const transferButton = getByTestId( + MoneyActionButtonRowTestIds.TRANSFER_BUTTON, + ); + expect(transferButton.props.accessibilityState?.disabled).toBe(true); + }); + + it('hides MoneyEarnings for dust balance', () => { + const { queryByTestId } = renderWithProvider(); + expect( + queryByTestId(MoneyEarningsTestIds.CONTAINER), + ).not.toBeOnTheScreen(); + }); + + it('shows unfunded onboarding sections for dust balance', () => { + const { getByTestId } = renderWithProvider(); + expect(getByTestId(MoneyHowItWorksTestIds.CONTAINER)).toBeOnTheScreen(); + expect(getByTestId(MoneyWhatYouGetTestIds.CONTAINER)).toBeOnTheScreen(); + }); + }); + + describe('missing-rate (unavailable) state', () => { + beforeEach(() => { + mockUseMoneyAccountBalance.mockReturnValue({ + totalFiatFormatted: undefined, + totalFiatRaw: undefined, + isAggregatedBalanceLoading: false, + isBalanceFetchError: false, + isBalanceFetching: false, + refetchBalance: jest.fn(), + apyPercent: 5, + vaultApyQuery: { data: { apy: 0.05 }, isLoading: false }, + musdBalanceQuery: { data: undefined, isLoading: false }, + musdEquivalentBalanceQuery: { data: undefined, isLoading: false }, + } as unknown as ReturnType); + mockUseMoneyAccountTransactions.mockReturnValue({ + allTransactions: [], + deposits: [], + transfers: [], + submittedTransactions: [], + moneyAddress: '0x0000000000000000000000000000000000000001', + mockDataEnabled: false, + }); + }); + + it('disables Transfer button', () => { + const { getByTestId } = renderWithProvider(); + const transferButton = getByTestId( + MoneyActionButtonRowTestIds.TRANSFER_BUTTON, + ); + expect(transferButton.props.accessibilityState?.disabled).toBe(true); + }); + + it('hides MoneyEarnings', () => { + const { queryByTestId } = renderWithProvider(); + expect( + queryByTestId(MoneyEarningsTestIds.CONTAINER), + ).not.toBeOnTheScreen(); + }); + + it('hides unfunded onboarding sections — does not fake an empty account', () => { + const { queryByTestId } = renderWithProvider(); + expect( + queryByTestId(MoneyHowItWorksTestIds.CONTAINER), + ).not.toBeOnTheScreen(); + expect( + queryByTestId(MoneyWhatYouGetTestIds.CONTAINER), + ).not.toBeOnTheScreen(); + }); + }); + + describe('spendable balance regression guard', () => { + it('enables Transfer button for a balance above the dust threshold', () => { + const { getByTestId } = renderWithProvider(); + const transferButton = getByTestId( + MoneyActionButtonRowTestIds.TRANSFER_BUTTON, + ); + expect(transferButton.props.accessibilityState?.disabled).toBeFalsy(); + }); + + it('shows MoneyEarnings for a balance above the dust threshold', () => { + const { getByTestId } = renderWithProvider(); + expect(getByTestId(MoneyEarningsTestIds.CONTAINER)).toBeOnTheScreen(); + }); + }); + + it('hides MoneyHowItWorks in noAccount state (displayState is noAccount, not balance)', () => { mockUseMoneyAccountInfo.mockReturnValue({ hasMoneyAccount: false, primaryMoneyAccount: undefined, @@ -593,7 +713,6 @@ describe('MoneyHomeView', () => { mockUseMoneyAccountBalance.mockReturnValue({ totalFiatFormatted: '$0.00', totalFiatRaw: '0', - tokenTotal: new BigNumber(0), isAggregatedBalanceLoading: false, isBalanceFetchError: false, isBalanceFetching: false, @@ -612,9 +731,12 @@ describe('MoneyHomeView', () => { mockDataEnabled: false, }); - const { getByTestId } = renderWithProvider(); + const { queryByTestId } = renderWithProvider(); - expect(getByTestId(MoneyHowItWorksTestIds.CONTAINER)).toBeOnTheScreen(); + // hasBalanceValue is false in noAccount state, so unfunded sections are hidden. + expect( + queryByTestId(MoneyHowItWorksTestIds.CONTAINER), + ).not.toBeOnTheScreen(); }); }); @@ -693,6 +815,62 @@ describe('MoneyHomeView', () => { }); }); + describe('transfer button disabled state', () => { + it('disables Transfer button when initial balance is loading', () => { + mockUseMoneyAccountBalance.mockReturnValue({ + ...mockUseMoneyAccountBalance.mock.results[0]?.value, + tokenTotal: undefined, + isAggregatedBalanceLoading: true, + isBalanceFetchError: false, + isBalanceFetching: true, + } as unknown as ReturnType); + + const { getByTestId } = renderWithProvider(); + const transferButton = getByTestId( + MoneyActionButtonRowTestIds.TRANSFER_BUTTON, + ); + + expect(transferButton.props.accessibilityState?.disabled).toBe(true); + }); + + it('disables Transfer button when account has zero balance', () => { + mockUseMoneyAccountBalance.mockReturnValue({ + ...mockUseMoneyAccountBalance.mock.results[0]?.value, + totalFiatFormatted: '$0.00', + totalFiatRaw: '0', + isAggregatedBalanceLoading: false, + isBalanceFetchError: false, + isBalanceFetching: false, + } as unknown as ReturnType); + + const { getByTestId } = renderWithProvider(); + const transferButton = getByTestId( + MoneyActionButtonRowTestIds.TRANSFER_BUTTON, + ); + + expect(transferButton.props.accessibilityState?.disabled).toBe(true); + }); + + it('does not navigate to Transfer sheet when Transfer button is pressed while disabled', () => { + mockUseMoneyAccountBalance.mockReturnValue({ + ...mockUseMoneyAccountBalance.mock.results[0]?.value, + totalFiatFormatted: '$0.00', + totalFiatRaw: '0', + isAggregatedBalanceLoading: false, + isBalanceFetchError: false, + isBalanceFetching: false, + } as unknown as ReturnType); + + const { getByTestId } = renderWithProvider(); + + fireEvent.press(getByTestId(MoneyActionButtonRowTestIds.TRANSFER_BUTTON)); + + expect(mockNavigate).not.toHaveBeenCalledWith(Routes.MONEY.MODALS.ROOT, { + screen: Routes.MONEY.MODALS.TRANSFER_MONEY_SHEET, + }); + }); + }); + it('navigates to Card root when Card button is pressed', () => { const { getByTestId } = renderWithProvider(); @@ -827,12 +1005,14 @@ describe('MoneyHomeView', () => { it('drops the + prefix when projected earnings round to formatted zero', () => { mockMoneyFormatFiat.mockReturnValue('$0.00'); + // totalFiatRaw must be >= DUST_THRESHOLD so hasSpendableBalance is true + // and MoneyEarnings renders. moneyFormatFiat is mocked to return '$0.00' + // for all values, exercising the no-plus-prefix path. mockUseMoneyAccountBalance.mockReturnValue({ - totalFiatFormatted: '$0.00', - musdFiatFormatted: '$0.00', - musdSHFvdFiatFormatted: '$0.00', - totalFiatRaw: '0.001', - tokenTotal: undefined, + totalFiatFormatted: '$3.00', + musdFiatFormatted: '$1.00', + musdSHFvdFiatFormatted: '$2.00', + totalFiatRaw: '3', isAggregatedBalanceLoading: false, apyDecimal: 0.05, apyPercent: 5, @@ -1212,13 +1392,12 @@ describe('MoneyHomeView', () => { }); }); - describe('balance not ready (tokenTotal undefined)', () => { + describe('balance not ready (loading state)', () => { beforeEach(() => { mockUseMoneyAccountBalance.mockReturnValue({ - totalFiatFormatted: '$3.00', - totalFiatRaw: '3', - tokenTotal: undefined, - isAggregatedBalanceLoading: false, + totalFiatFormatted: undefined, + totalFiatRaw: undefined, + isAggregatedBalanceLoading: true, isBalanceFetchError: false, isBalanceFetching: false, refetchBalance: mockRefetchBalance, diff --git a/app/components/UI/Money/Views/MoneyHomeView/MoneyHomeView.tsx b/app/components/UI/Money/Views/MoneyHomeView/MoneyHomeView.tsx index 252ddea71c0..6e646425bb0 100644 --- a/app/components/UI/Money/Views/MoneyHomeView/MoneyHomeView.tsx +++ b/app/components/UI/Money/Views/MoneyHomeView/MoneyHomeView.tsx @@ -31,8 +31,7 @@ import MoneyActivityLoading from '../../components/MoneyActivityLoading/MoneyAct import useMoneyAccountBalance from '../../hooks/useMoneyAccountBalance'; import useMoneyAccountInfo from '../../hooks/useMoneyAccountInfo'; import { selectCurrentCurrency } from '../../../../../selectors/currencyRateController'; -import { moneyFormatFiat } from '../../utils/moneyFormatFiat'; -import { isAccountFunded } from '../../utils/isAccountFunded'; +import { moneyFormatFiat, DUST_THRESHOLD } from '../../utils/moneyFormatFiat'; import { calculateProjectedEarnings } from '../../utils/projections'; import { MUSD_MAINNET_ASSET_FOR_DETAILS } from '../../../../Views/Homepage/Sections/Cash/CashGetMusdEmptyState.constants'; import { TokenDetailsSource } from '../../../TokenDetails/constants/constants'; @@ -63,7 +62,6 @@ import { MONEY_URLS, MONEY_BUTTON_TYPES, } from '../../constants/moneyEvents'; -import { strings } from '../../../../../../locales/i18n'; import { TransactionMeta } from '@metamask/transaction-controller'; const Divider = () => ; @@ -92,7 +90,6 @@ const MoneyHomeView = () => { const { totalFiatFormatted, totalFiatRaw, - tokenTotal, vaultApyQuery, isAggregatedBalanceLoading, isBalanceFetchError, @@ -148,9 +145,6 @@ const MoneyHomeView = () => { isLinking, } = useMoneyAccountCardLinkage(); - const balanceReady = tokenTotal !== undefined; - const isFunded = isAccountFunded(tokenTotal) || activityItems.length > 0; - let displayState: MoneyBalanceDisplayState; if (!hasMoneyAccount) { displayState = { kind: 'noAccount' }; @@ -166,6 +160,13 @@ const MoneyHomeView = () => { displayState = { kind: 'balance', value: totalFiatFormatted }; } + const hasBalanceValue = displayState.kind === 'balance'; + const hasSpendableBalance = + hasBalanceValue && + !!totalFiatRaw && + new BigNumber(totalFiatRaw).abs().gte(DUST_THRESHOLD); + const isFunded = hasSpendableBalance || activityItems.length > 0; + const formattedZero = useMemo( () => moneyFormatFiat(new BigNumber(0), currentCurrency), [currentCurrency], @@ -540,35 +541,34 @@ const MoneyHomeView = () => { onApyInfoPress={handleApyInfoPress} /> - handleAddPress({ - labelKey: 'money.action.add', - componentName: COMPONENT_NAMES.MONEY_ACTION_BUTTON_ROW, - buttonPosition: 1, - buttonRowButtonCount: ACTION_BUTTON_ROW_BUTTON_COUNT, - }) - } - onTransferPress={handleTransferPress} - onCardPress={handleActionButtonCardPress} + add={{ + onPress: () => + handleAddPress({ + labelKey: 'money.action.add', + componentName: COMPONENT_NAMES.MONEY_ACTION_BUTTON_ROW, + buttonPosition: 1, + buttonRowButtonCount: ACTION_BUTTON_ROW_BUTTON_COUNT, + }), + }} + transfer={{ + onPress: handleTransferPress, + disabled: !hasSpendableBalance, + }} + card={{ onPress: handleActionButtonCardPress }} /> - {/* Only show earnings if balance is available and non-zero */} - {displayState.kind === 'balance' && - totalFiatRaw && - !new BigNumber(totalFiatRaw).isZero() && ( - <> - - - - )} - {balanceReady && !isFunded && ( + {hasSpendableBalance && ( + <> + + + + )} + {hasBalanceValue && !isFunded && ( <> { )} - {balanceReady && !isFunded && ( + {hasBalanceValue && !isFunded && ( { it('renders all three action buttons', () => { - const { getByText } = render( - , - ); + const { getByText } = render(); expect(getByText(strings('money.action.add'))).toBeOnTheScreen(); expect(getByText(strings('money.action.transfer'))).toBeOnTheScreen(); expect(getByText(strings('money.action.card'))).toBeOnTheScreen(); }); - it('calls onAddPress when Add button is pressed', () => { + it('calls add.onPress when Add button is pressed', () => { const mockAdd = jest.fn(); const { getByTestId } = render( - , + , ); fireEvent.press(getByTestId(MoneyActionButtonRowTestIds.ADD_BUTTON)); @@ -36,13 +32,12 @@ describe('MoneyActionButtonRow', () => { expect(mockAdd).toHaveBeenCalledTimes(1); }); - it('calls onTransferPress when Transfer button is pressed', () => { + it('calls transfer.onPress when Transfer button is pressed', () => { const mockTransfer = jest.fn(); const { getByTestId } = render( , ); @@ -51,18 +46,56 @@ describe('MoneyActionButtonRow', () => { expect(mockTransfer).toHaveBeenCalledTimes(1); }); - it('calls onCardPress when Card button is pressed', () => { + it('calls card.onPress when Card button is pressed', () => { + const mockCard = jest.fn(); + const { getByTestId } = render( + , + ); + + fireEvent.press(getByTestId(MoneyActionButtonRowTestIds.CARD_BUTTON)); + + expect(mockCard).toHaveBeenCalledTimes(1); + }); + + it('does not call add.onPress when Add button is disabled', () => { + const mockAdd = jest.fn(); + const { getByTestId } = render( + , + ); + + fireEvent.press(getByTestId(MoneyActionButtonRowTestIds.ADD_BUTTON)); + + expect(mockAdd).not.toHaveBeenCalled(); + }); + + it('does not call transfer.onPress when Transfer button is disabled', () => { + const mockTransfer = jest.fn(); + const { getByTestId } = render( + , + ); + + fireEvent.press(getByTestId(MoneyActionButtonRowTestIds.TRANSFER_BUTTON)); + + expect(mockTransfer).not.toHaveBeenCalled(); + }); + + it('does not call card.onPress when Card button is disabled', () => { const mockCard = jest.fn(); const { getByTestId } = render( , ); fireEvent.press(getByTestId(MoneyActionButtonRowTestIds.CARD_BUTTON)); - expect(mockCard).toHaveBeenCalledTimes(1); + expect(mockCard).not.toHaveBeenCalled(); }); }); diff --git a/app/components/UI/Money/components/MoneyActionButtonRow/MoneyActionButtonRow.tsx b/app/components/UI/Money/components/MoneyActionButtonRow/MoneyActionButtonRow.tsx index e4b8da891f3..150c7820991 100644 --- a/app/components/UI/Money/components/MoneyActionButtonRow/MoneyActionButtonRow.tsx +++ b/app/components/UI/Money/components/MoneyActionButtonRow/MoneyActionButtonRow.tsx @@ -1,4 +1,4 @@ -import React, { useCallback } from 'react'; +import React from 'react'; import { Box, BoxAlignItems, @@ -17,10 +17,15 @@ import { import { strings } from '../../../../../../locales/i18n'; import { MoneyActionButtonRowTestIds } from './MoneyActionButtonRow.testIds'; +interface ActionButtonConfig { + onPress: () => void; + disabled?: boolean; +} + interface MoneyActionButtonRowProps { - onAddPress: () => void; - onTransferPress: () => void; - onCardPress: () => void; + add: ActionButtonConfig; + transfer: ActionButtonConfig; + card: ActionButtonConfig; } const ActionButton = ({ @@ -28,16 +33,19 @@ const ActionButton = ({ label, onPress, testID, + disabled, }: { iconName: IconName; label: string; onPress: () => void; testID: string; + disabled?: boolean; }) => ( { - const handleAddPress = useCallback(() => onAddPress(), [onAddPress]); - const handleTransferPress = useCallback( - () => onTransferPress(), - [onTransferPress], - ); - const handleCardPress = useCallback(() => onCardPress(), [onCardPress]); - - return ( - - - - - - ); -}; + add, + transfer, + card, +}: MoneyActionButtonRowProps) => ( + + + + + +); export default MoneyActionButtonRow; diff --git a/app/components/UI/Money/utils/isAccountFunded.test.ts b/app/components/UI/Money/utils/isAccountFunded.test.ts deleted file mode 100644 index dc39b2cc5d2..00000000000 --- a/app/components/UI/Money/utils/isAccountFunded.test.ts +++ /dev/null @@ -1,21 +0,0 @@ -import { BigNumber } from 'bignumber.js'; -import { isAccountFunded } from './isAccountFunded'; - -describe('isAccountFunded', () => { - it('returns false when tokenTotal is undefined', () => { - expect(isAccountFunded(undefined)).toBe(false); - }); - - it('returns false when tokenTotal is zero', () => { - expect(isAccountFunded(new BigNumber(0))).toBe(false); - }); - - it('returns true when tokenTotal is positive', () => { - expect(isAccountFunded(new BigNumber('0.01'))).toBe(true); - expect(isAccountFunded(new BigNumber(100))).toBe(true); - }); - - it('returns false when tokenTotal is negative', () => { - expect(isAccountFunded(new BigNumber(-1))).toBe(false); - }); -}); diff --git a/app/components/UI/Money/utils/isAccountFunded.ts b/app/components/UI/Money/utils/isAccountFunded.ts deleted file mode 100644 index 5479db8539c..00000000000 --- a/app/components/UI/Money/utils/isAccountFunded.ts +++ /dev/null @@ -1,4 +0,0 @@ -import type BigNumber from 'bignumber.js'; - -export const isAccountFunded = (tokenTotal: BigNumber | undefined): boolean => - tokenTotal !== undefined && tokenTotal.gt(0); diff --git a/app/components/UI/Money/utils/moneyFormatFiat.ts b/app/components/UI/Money/utils/moneyFormatFiat.ts index ec087f1cece..ad444adf914 100644 --- a/app/components/UI/Money/utils/moneyFormatFiat.ts +++ b/app/components/UI/Money/utils/moneyFormatFiat.ts @@ -4,7 +4,7 @@ import { getLocaleLanguageCode } from '../../../hooks/useFormatters'; // One cent. Values strictly below this collapse to $0.00 — mUSD is USD-pegged // so sub-cent fiat is economically meaningless. -const THRESHOLD = 0.01; +export const DUST_THRESHOLD = 0.01; /** * Helper that wraps formatWithThreshold to centralize Money formatting logic. @@ -19,10 +19,10 @@ export const moneyFormatFiat = ( value: BigNumber, currentCurrency: string, ): string => { - const effective = value.abs().lt(THRESHOLD) ? new BigNumber(0) : value; + const effective = value.abs().lt(DUST_THRESHOLD) ? new BigNumber(0) : value; return formatWithThreshold( effective.toNumber(), - THRESHOLD, + DUST_THRESHOLD, getLocaleLanguageCode(), { style: 'currency', From 2b6656bbbc7ae2ac29eeb27fa409923b45121822 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Jo=C3=A3o=20Santos?= Date: Wed, 10 Jun 2026 15:29:31 +0200 Subject: [PATCH 05/15] fix: default QuickBuy sell receive token to chain native instead of the sold token (#31469) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ## **Description** When selling a token in QuickBuy, the **receive** token list could contain — and default to — the **same token being sold**, which is a no-op (e.g. selling USDC on Base offered/defaulted to receiving USDC). The receive options are built stablecoins-first and sorted with the position's chain first, so the previous logic — which simply preselected `sellDestTokenOptions[0]` — landed on the very token being sold whenever the position was a stablecoin that also appears in the curated stablecoin list. The token was also still selectable in the picker. This PR fixes both the default and the list: - **Excludes the token being sold from the receive list entirely**, so it can't be selected at all (receiving the same token you're selling is a no-op). - Introduces `selectDefaultReceiveToken`, which prefers the **native token of the sold token's chain** as the default (e.g. selling USDC on Base now defaults to ETH on Base), falling back to the first remaining candidate when the user is already selling the native token or when no native option is available. The auto-select effect is gated on `!isSetupLoading` and reads the sold token's identity from the normalised `positionTokenFromSetup` (rather than the balance-enriched `positionToken`), so both the exclusion and the default are correct even before the balance resolves — closing a race where the default could still be set to the sold token. image ## **Changelog** CHANGELOG entry: Fixed QuickBuy so selling a token no longer lists or defaults the receive token to the same token; the sold token is removed from the receive options and the default is now the chain's native token (e.g. selling USDC on Base defaults to ETH). ## **Related issues** Fixes: N/A — internal bug report (QuickBuy sell default/listing of receive token). ## **Manual testing steps** ```gherkin Feature: QuickBuy sell receive token Scenario: The token being sold is not offered as a receive option Given I hold USDC on Base And I open the QuickBuy sheet for that position When I switch to "Sell" mode And I open the "Receive" token picker Then USDC on Base is not listed as a receive option Scenario: Selling a stablecoin defaults to the chain native token Given I hold USDC on Base And I open the QuickBuy sheet for that position When I switch to "Sell" mode Then the default "Receive" token is ETH on Base And it is never USDC (the token being sold) Scenario: Selling the native token defaults to a stablecoin Given I hold ETH on Base And I open the QuickBuy sheet for that position When I switch to "Sell" mode Then the default "Receive" token is a stablecoin on Base (not ETH) ``` ## **Screenshots/Recordings** ### **Before** N/A — logic-only change; behavior covered by unit tests. ### **After** N/A — logic-only change; behavior covered by unit tests. ## **Pre-merge author checklist** - [x] I've followed [MetaMask Contributor Docs](https://github.com/MetaMask/contributor-docs) and [MetaMask Mobile Coding Standards](https://github.com/MetaMask/metamask-mobile/blob/main/.github/guidelines/CODING_GUIDELINES.md). - [x] I've completed the PR template to the best of my ability - [x] I've included tests if applicable - [x] I've documented my code using [JSDoc](https://jsdoc.app/) format if applicable - [x] I've applied the right labels on the PR (see [labeling guidelines](https://github.com/MetaMask/metamask-mobile/blob/main/.github/guidelines/LABELING_GUIDELINES.md)). Not required for external contributors. #### Performance checks (if applicable) - [x] I've tested on Android - Ideally on a mid-range device; emulator is acceptable - [x] I've tested with a power user scenario - Use these [power-user SRPs](https://consensyssoftware.atlassian.net/wiki/spaces/TL1/pages/edit-v2/401401446401?draftShareId=9d77e1e1-4bdc-4be1-9ebb-ccd916988d93) to import wallets with many accounts and tokens - [x] I've instrumented key operations with Sentry traces for production performance metrics - See [`trace()`](/app/util/trace.ts) for usage and [`addToken`](/app/components/Views/AddAsset/components/AddCustomToken/AddCustomToken.tsx#L274) for an example For performance guidelines and tooling, see the [Performance Guide](https://consensyssoftware.atlassian.net/wiki/spaces/TL1/pages/400085549067/Performance+Guide+for+Engineers). ## **Pre-merge reviewer checklist** - [ ] I've manually tested the PR (e.g. pull and build branch, run the app, test code being changed). - [ ] I confirm that this PR addresses all acceptance criteria described in the ticket it closes and includes the necessary testing evidence such as recordings and or screenshots. --------- Co-authored-by: Claude Opus 4.8 (1M context) --- .../QuickBuy/hooks/useQuickBuyController.ts | 37 ++++++- .../components/QuickBuy/tokenKey.ts | 5 +- .../QuickBuy/useQuickBuyController.test.ts | 103 ++++++++++++++++++ .../utils/selectDefaultReceiveToken.test.ts | 73 +++++++++++++ .../utils/selectDefaultReceiveToken.ts | 48 ++++++++ 5 files changed, 259 insertions(+), 7 deletions(-) create mode 100644 app/components/Views/SocialLeaderboard/TraderPositionView/components/QuickBuy/utils/selectDefaultReceiveToken.test.ts create mode 100644 app/components/Views/SocialLeaderboard/TraderPositionView/components/QuickBuy/utils/selectDefaultReceiveToken.ts diff --git a/app/components/Views/SocialLeaderboard/TraderPositionView/components/QuickBuy/hooks/useQuickBuyController.ts b/app/components/Views/SocialLeaderboard/TraderPositionView/components/QuickBuy/hooks/useQuickBuyController.ts index e8aedb2dd02..8a1f5c14ea4 100644 --- a/app/components/Views/SocialLeaderboard/TraderPositionView/components/QuickBuy/hooks/useQuickBuyController.ts +++ b/app/components/Views/SocialLeaderboard/TraderPositionView/components/QuickBuy/hooks/useQuickBuyController.ts @@ -36,8 +36,10 @@ import type { QuickBuyTarget, QuickBuyTradeMode, } from '../types'; +import { getTokenKey } from '../tokenKey'; import { formatExchangeRate } from '../utils/formatExchangeRate'; import { getMetamaskFeePercent } from '../utils/getMetamaskFeePercent'; +import { selectDefaultReceiveToken } from '../utils/selectDefaultReceiveToken'; import { usePayWithTokens } from './usePayWithTokens'; import { usePositionTokenBalance } from './usePositionTokenBalance'; import { useQuickBuyAnalytics } from './useQuickBuyAnalytics'; @@ -354,20 +356,45 @@ export function useQuickBuyController( const positionToken = usePositionTokenBalance(target, positionTokenFromSetup); // ─── Sell "Receive" options (stablecoins) ────────────────────────────── - const sellDestTokenOptions = useReceiveTokens( + const receiveTokenOptions = useReceiveTokens( destChainId as string | undefined, ); + // Exclude the token being sold from the "Receive" list entirely — receiving + // the same token you're selling is a no-op, so it must not be selectable. + // Identity comes from `positionTokenFromSetup` (normalised address/chainId). + const sellDestTokenOptions = useMemo(() => { + if (!positionTokenFromSetup) return receiveTokenOptions; + const soldKey = getTokenKey(positionTokenFromSetup); + return receiveTokenOptions.filter( + (token) => getTokenKey(token) !== soldKey, + ); + }, [receiveTokenOptions, positionTokenFromSetup]); const [selectedDestStable, setSelectedDestStable] = useState< BridgeToken | undefined >(undefined); - // Auto-select default dest stable: prefer a token the user already holds on - // the position chain; fall back to the first candidate (USDC on position chain). + // Auto-select the default receive token. Prefer the native token of the + // position's chain (e.g. selling USDC on Base defaults to ETH on Base) and + // never the same token being sold — see `selectDefaultReceiveToken`. + // + // Wait for `!isSetupLoading` so the sold token's address is normalised before + // the (one-shot) selection runs. The sold token's identity is read from + // `positionTokenFromSetup` (which carries the normalised address/chainId) + // rather than the balance-enriched `positionToken`, so the exclusion still + // works even when the balance is still resolving. useEffect(() => { + if (isSetupLoading) return; if (sellDestTokenOptions.length > 0 && !selectedDestStable) { - setSelectedDestStable(sellDestTokenOptions[0]); + setSelectedDestStable( + selectDefaultReceiveToken(sellDestTokenOptions, positionTokenFromSetup), + ); } - }, [sellDestTokenOptions, selectedDestStable]); + }, [ + isSetupLoading, + sellDestTokenOptions, + selectedDestStable, + positionTokenFromSetup, + ]); // ─── Source / dest resolution (mode-dependent) ───────────────────────── const sourceToken = tradeMode === 'buy' ? selectedSourceToken : positionToken; diff --git a/app/components/Views/SocialLeaderboard/TraderPositionView/components/QuickBuy/tokenKey.ts b/app/components/Views/SocialLeaderboard/TraderPositionView/components/QuickBuy/tokenKey.ts index de6e7bd55ba..ff94f020554 100644 --- a/app/components/Views/SocialLeaderboard/TraderPositionView/components/QuickBuy/tokenKey.ts +++ b/app/components/Views/SocialLeaderboard/TraderPositionView/components/QuickBuy/tokenKey.ts @@ -6,5 +6,6 @@ import type { BridgeToken } from '../../../../../UI/Bridge/types'; * both the "Pay with" (buy) and "Receive" (sell) flows so row keys and * selection comparisons behave identically. */ -export const getTokenKey = (token: BridgeToken): string => - `${token.address.toLowerCase()}:${token.chainId}`; +export const getTokenKey = ( + token: Pick, +): string => `${token.address.toLowerCase()}:${token.chainId}`; diff --git a/app/components/Views/SocialLeaderboard/TraderPositionView/components/QuickBuy/useQuickBuyController.test.ts b/app/components/Views/SocialLeaderboard/TraderPositionView/components/QuickBuy/useQuickBuyController.test.ts index 0013c545b50..102dbf28375 100644 --- a/app/components/Views/SocialLeaderboard/TraderPositionView/components/QuickBuy/useQuickBuyController.test.ts +++ b/app/components/Views/SocialLeaderboard/TraderPositionView/components/QuickBuy/useQuickBuyController.test.ts @@ -1511,6 +1511,109 @@ describe('useQuickBuyController', () => { }); }); + describe('receive token auto-selection', () => { + const NATIVE_ADDRESS = '0x0000000000000000000000000000000000000000'; + const USDC_DEST = '0xDEST'; // matches the default-mock position token + + it('defaults the receive token to the chain native instead of the token being sold', () => { + // Selling 0xDEST on chain 0x1. The receive options are stablecoins-first, + // so options[0] is the very token being sold — the default must skip it + // and pick the chain's native token instead. + const usdcDest = createSourceToken({ + address: USDC_DEST, + chainId: '0x1', + symbol: 'USDC', + }); + const nativeDest = createSourceToken({ + address: NATIVE_ADDRESS, + chainId: '0x1', + symbol: 'ETH', + }); + (useReceiveTokens as jest.Mock).mockReturnValue([usdcDest, nativeDest]); + + const { result } = renderHook(() => + useQuickBuyController(createTarget(), jest.fn()), + ); + + expect(result.current.selectedDestStable?.symbol).toBe('ETH'); + }); + + it('falls back to the first non-sold candidate when selling the native token', () => { + (useQuickBuySetup as jest.Mock).mockReturnValue({ + chainId: '0x1', + destToken: { + address: NATIVE_ADDRESS, + chainId: '0x1', + decimals: 18, + symbol: 'ETH', + name: 'Ether', + }, + isLoading: false, + isUnsupportedChain: false, + }); + const nativeDest = createSourceToken({ + address: NATIVE_ADDRESS, + chainId: '0x1', + symbol: 'ETH', + }); + const usdcDest = createSourceToken({ + address: USDC_DEST, + chainId: '0x1', + symbol: 'USDC', + }); + (useReceiveTokens as jest.Mock).mockReturnValue([nativeDest, usdcDest]); + + const { result } = renderHook(() => + useQuickBuyController(createTarget(), jest.fn()), + ); + + expect(result.current.selectedDestStable?.symbol).toBe('USDC'); + }); + + it('excludes the token being sold from the receive options entirely', () => { + const usdcDest = createSourceToken({ + address: USDC_DEST, + chainId: '0x1', + symbol: 'USDC', + }); + const nativeDest = createSourceToken({ + address: NATIVE_ADDRESS, + chainId: '0x1', + symbol: 'ETH', + }); + (useReceiveTokens as jest.Mock).mockReturnValue([usdcDest, nativeDest]); + + const { result } = renderHook(() => + useQuickBuyController(createTarget(), jest.fn()), + ); + + const symbols = result.current.sellDestTokenOptions.map((t) => t.symbol); + expect(symbols).not.toContain('USDC'); + expect(symbols).toEqual(['ETH']); + }); + + it('does not auto-select while setup is still loading', () => { + (useQuickBuySetup as jest.Mock).mockReturnValue({ + chainId: '0x1', + destToken: undefined, + isLoading: true, + isUnsupportedChain: false, + }); + const usdcDest = createSourceToken({ + address: USDC_DEST, + chainId: '0x1', + symbol: 'USDC', + }); + (useReceiveTokens as jest.Mock).mockReturnValue([usdcDest]); + + const { result } = renderHook(() => + useQuickBuyController(createTarget(), jest.fn()), + ); + + expect(result.current.selectedDestStable).toBeUndefined(); + }); + }); + describe('selectDefaultSourceToken', () => { const native = (chainId: string, fiat = 1000): BridgeToken => createSourceToken({ diff --git a/app/components/Views/SocialLeaderboard/TraderPositionView/components/QuickBuy/utils/selectDefaultReceiveToken.test.ts b/app/components/Views/SocialLeaderboard/TraderPositionView/components/QuickBuy/utils/selectDefaultReceiveToken.test.ts new file mode 100644 index 00000000000..6669a68d016 --- /dev/null +++ b/app/components/Views/SocialLeaderboard/TraderPositionView/components/QuickBuy/utils/selectDefaultReceiveToken.test.ts @@ -0,0 +1,73 @@ +import type { Hex } from '@metamask/utils'; +import type { BridgeToken } from '../../../../../../UI/Bridge/types'; +import { selectDefaultReceiveToken } from './selectDefaultReceiveToken'; + +const BASE = '0x2105' as Hex; +const POLYGON = '0x89' as Hex; +const NATIVE_ADDRESS = '0x0000000000000000000000000000000000000000'; +const USDC_BASE = '0x833589fcd6edb6e08f4c7c32d4f71b54bda02913'; +const USDC_POLYGON = '0x3c499c542cef5e3811e1192ce70d8cc03d5c3359'; +const USDT_POLYGON = '0xc2132d05d31c914a87c6611c10748aeb04b58e8f'; + +const token = (symbol: string, address: string, chainId: Hex): BridgeToken => + ({ + symbol, + address, + chainId, + decimals: 6, + }) as BridgeToken; + +const usdcBase = token('USDC', USDC_BASE, BASE); +const ethBase = token('ETH', NATIVE_ADDRESS, BASE); +const usdcPolygon = token('USDC', USDC_POLYGON, POLYGON); +const usdtPolygon = token('USDT', USDT_POLYGON, POLYGON); +const polPolygon = token('POL', NATIVE_ADDRESS, POLYGON); + +describe('selectDefaultReceiveToken', () => { + it('returns undefined when there are no options', () => { + expect(selectDefaultReceiveToken([], usdcBase)).toBeUndefined(); + }); + + it('returns the first option when the sold token is unknown', () => { + const options = [usdcBase, ethBase]; + expect(selectDefaultReceiveToken(options, undefined)).toBe(usdcBase); + }); + + it('defaults to the native token of the chain when selling a stablecoin', () => { + // Receive options are ordered stablecoins-first on the position chain, so a + // naive options[0] would return USDC — the very token being sold. + const options = [usdcBase, ethBase, usdcPolygon]; + expect(selectDefaultReceiveToken(options, usdcBase)).toBe(ethBase); + }); + + it('matches the sold token case-insensitively before excluding it', () => { + const options = [usdcBase, ethBase]; + const soldUpperCase = token('USDC', USDC_BASE.toUpperCase(), BASE); + expect(selectDefaultReceiveToken(options, soldUpperCase)).toBe(ethBase); + }); + + it('falls back to the first eligible candidate when selling the native token', () => { + // Selling ETH on Base: native preference would re-select ETH, so we fall + // through to the first non-ETH candidate. + const options = [ethBase, usdcBase]; + expect(selectDefaultReceiveToken(options, ethBase)).toBe(usdcBase); + }); + + it('falls back to the first eligible candidate when the native token is not an option', () => { + // No native candidate present for the sold token's chain. + const options = [usdcPolygon, usdtPolygon]; + expect(selectDefaultReceiveToken(options, usdcPolygon)).toBe(usdtPolygon); + }); + + it('only prefers the native token on the sold token chain', () => { + // Native ETH/Base must not be chosen when selling on Polygon — the native + // preference is scoped to the sold token's own chain. + const options = [usdcPolygon, polPolygon, ethBase]; + expect(selectDefaultReceiveToken(options, usdcPolygon)).toBe(polPolygon); + }); + + it('returns the first option when every candidate is the sold token', () => { + const options = [usdcBase]; + expect(selectDefaultReceiveToken(options, usdcBase)).toBe(usdcBase); + }); +}); diff --git a/app/components/Views/SocialLeaderboard/TraderPositionView/components/QuickBuy/utils/selectDefaultReceiveToken.ts b/app/components/Views/SocialLeaderboard/TraderPositionView/components/QuickBuy/utils/selectDefaultReceiveToken.ts new file mode 100644 index 00000000000..02c4ce5c40b --- /dev/null +++ b/app/components/Views/SocialLeaderboard/TraderPositionView/components/QuickBuy/utils/selectDefaultReceiveToken.ts @@ -0,0 +1,48 @@ +import type { BridgeToken } from '../../../../../../UI/Bridge/types'; +import { getNativeSourceToken } from '../../../../../../UI/Bridge/utils/tokenUtils'; +import { getTokenKey } from '../tokenKey'; + +/** + * Picks the default "Receive" token for QuickBuy Sell mode. + * + * The receive options are ordered stablecoins-first on the position's chain, so + * naively taking the first entry can default to the *same* token the user is + * selling (e.g. selling USDC on Base would default to receiving USDC, which is a + * no-op). To keep the default sensible this excludes the token being sold and + * then prefers the native token of the sold token's chain (e.g. ETH on Base) so + * a sale defaults to cashing out into the chain's native asset. When the user is + * already selling the native token, or no native option exists, it falls back to + * the first remaining candidate (a stablecoin on the position chain). + * + * @param options - The ordered receive-token candidates. + * @param soldToken - The token being sold (Sell mode source), if known. + * @returns The token to preselect, or `undefined` when there are no options. + */ +export const selectDefaultReceiveToken = ( + options: BridgeToken[], + soldToken: Pick | undefined, +): BridgeToken | undefined => { + if (options.length === 0) return undefined; + if (!soldToken) return options[0]; + + const soldKey = getTokenKey(soldToken); + const eligible = options.filter((token) => getTokenKey(token) !== soldKey); + // Every option matched the sold token (shouldn't happen in practice) — fall + // back to the first so we always return something selectable. + if (eligible.length === 0) return options[0]; + + let nativeKey: string | undefined; + try { + nativeKey = getTokenKey(getNativeSourceToken(soldToken.chainId)); + } catch { + // Native asset can't be resolved for this chain — skip the native + // preference and fall through to the first eligible candidate. + } + + if (nativeKey && nativeKey !== soldKey) { + const native = eligible.find((token) => getTokenKey(token) === nativeKey); + if (native) return native; + } + + return eligible[0]; +}; From adb330f07713edcbee1994c3c82e5aac006785e5 Mon Sep 17 00:00:00 2001 From: Maarten Zuidhoorn Date: Wed, 10 Jun 2026 15:30:08 +0200 Subject: [PATCH 06/15] feat: Implement UI messengers (#29653) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ## **Description** Initial implementation of the UI messenger. This sets up the UI messenger, a route messenger, and refactors a Snap page to demonstrate how to use the new route-level messenger. More details can be found in [the UI messenger ADR](https://github.com/MetaMask/decisions/blob/6760a1616df98f5b8bb7c876fadd7f255a2b8957/decisions/core/0020-integrate-messengers-into-ui.md). ## **Changelog** CHANGELOG entry: ## **Related issues** Refs: n/a ## **Manual testing steps** n/a ## **Screenshots/Recordings** n/a ## **Pre-merge author checklist** - [x] I've followed [MetaMask Contributor Docs](https://github.com/MetaMask/contributor-docs) and [MetaMask Mobile Coding Standards](https://github.com/MetaMask/metamask-mobile/blob/main/.github/guidelines/CODING_GUIDELINES.md). - [x] I've completed the PR template to the best of my ability - [x] I've included tests if applicable - [x] I've documented my code using [JSDoc](https://jsdoc.app/) format if applicable - [x] I've applied the right labels on the PR (see [labeling guidelines](https://github.com/MetaMask/metamask-mobile/blob/main/.github/guidelines/LABELING_GUIDELINES.md)). Not required for external contributors. #### Performance checks (if applicable) - [x] I've tested on Android - Ideally on a mid-range device; emulator is acceptable - [x] I've tested with a power user scenario - Use these [power-user SRPs](https://consensyssoftware.atlassian.net/wiki/spaces/TL1/pages/edit-v2/401401446401?draftShareId=9d77e1e1-4bdc-4be1-9ebb-ccd916988d93) to import wallets with many accounts and tokens - [x] I've instrumented key operations with Sentry traces for production performance metrics - See [`trace()`](/app/util/trace.ts) for usage and [`addToken`](/app/components/Views/AddAsset/components/AddCustomToken/AddCustomToken.tsx#L274) for an example For performance guidelines and tooling, see the [Performance Guide](https://consensyssoftware.atlassian.net/wiki/spaces/TL1/pages/400085549067/Performance+Guide+for+Engineers). ## **Pre-merge reviewer checklist** - [ ] I've manually tested the PR (e.g. pull and build branch, run the app, test code being changed). - [ ] I confirm that this PR addresses all acceptance criteria described in the ticket it closes and includes the necessary testing evidence such as recordings and or screenshots. --- > [!NOTE] > **Medium Risk** > Changes how UI invokes controller actions (snap removal now goes through delegation) and introduces a broad new access-control surface; mitigated by explicit per-route capabilities and KeyringController exclusions, but more routes will need careful capability review as adoption grows. > > **Overview** > Introduces a **UI messenger layer** so screens can call controller actions through typed, capability-scoped messengers instead of reaching into `Engine` directly. > > **Root** now creates a singleton `UIMessenger` and wraps the app in `UIMessengerProvider`. The UI messenger delegates allowed actions to `Engine.controllerMessenger` and forwards events from the background; a config list (starting with **KeyringController** unsafe/keyring APIs) blocks actions that must not be exposed to the UI. > > **Per-route messengers** are wired via `withMessenger` / `RouteWithMessenger`: on mount they delegate only the declared actions/events, expose them through `RouteMessengerContext`, and revoke on unmount. **Snap Settings** is the first adopter—the navigator wraps `SnapSettings` with `SnapController:removeSnap`, and removal uses `useMessenger().call('SnapController:removeSnap', …)` instead of `Engine.context.SnapController.removeSnap`. Tests and `renderWithProvider` gain optional `uiMessenger` / `routeMessenger` mocks to match this pattern. > > Reviewed by [Cursor Bugbot](https://cursor.com/bugbot) for commit dbbcad3605177adcf32e57408b9e937a3e2a3359. Bugbot is set up for automated code reviews on this repo. Configure [here](https://www.cursor.com/dashboard/bugbot). --- app/components/Nav/Main/MainNavigator.js | 12 +- app/components/Views/Root/index.tsx | 29 +- .../Views/Snaps/SnapSettings/SnapSettings.tsx | 8 +- .../Views/Snaps/SnapSettings/index.ts | 1 + .../Views/Snaps/SnapSettings/messenger.ts | 11 + .../SnapSettings/test/SnapSettings.test.tsx | 41 ++- app/contexts/route-messenger.ts | 28 ++ app/contexts/ui-messenger.tsx | 48 +++ app/core/Engine/types.ts | 4 +- app/hooks/useMessenger.ts | 43 +++ app/messengers/configs/helpers.ts | 26 ++ app/messengers/configs/index.ts | 22 ++ app/messengers/configs/keyring-controller.ts | 17 + .../helpers/route-messenger-helpers.test.tsx | 65 ++++ .../helpers/route-messenger-helpers.tsx | 125 +++++++ app/messengers/route-messenger.test.ts | 38 +++ app/messengers/route-messenger.ts | 77 +++++ app/messengers/route-with-messenger.test.tsx | 94 +++++ app/messengers/route-with-messenger.tsx | 84 +++++ app/messengers/ui-messenger.test.ts | 298 ++++++++++++++++ app/messengers/ui-messenger.ts | 323 ++++++++++++++++++ app/util/test/mock-route-messenger.ts | 50 +++ app/util/test/mock-ui-messenger.ts | 71 ++++ app/util/test/renderWithProvider.tsx | 62 +++- 24 files changed, 1549 insertions(+), 28 deletions(-) create mode 100644 app/components/Views/Snaps/SnapSettings/messenger.ts create mode 100644 app/contexts/route-messenger.ts create mode 100644 app/contexts/ui-messenger.tsx create mode 100644 app/hooks/useMessenger.ts create mode 100644 app/messengers/configs/helpers.ts create mode 100644 app/messengers/configs/index.ts create mode 100644 app/messengers/configs/keyring-controller.ts create mode 100644 app/messengers/helpers/route-messenger-helpers.test.tsx create mode 100644 app/messengers/helpers/route-messenger-helpers.tsx create mode 100644 app/messengers/route-messenger.test.ts create mode 100644 app/messengers/route-messenger.ts create mode 100644 app/messengers/route-with-messenger.test.tsx create mode 100644 app/messengers/route-with-messenger.tsx create mode 100644 app/messengers/ui-messenger.test.ts create mode 100644 app/messengers/ui-messenger.ts create mode 100644 app/util/test/mock-route-messenger.ts create mode 100644 app/util/test/mock-ui-messenger.ts diff --git a/app/components/Nav/Main/MainNavigator.js b/app/components/Nav/Main/MainNavigator.js index 9a7ccd36307..b743f576615 100644 --- a/app/components/Nav/Main/MainNavigator.js +++ b/app/components/Nav/Main/MainNavigator.js @@ -86,7 +86,10 @@ import SendTransaction from '../../UI/Ramp/Aggregator/Views/SendTransaction'; import TabBar from '../../../component-library/components/Navigation/TabBar'; ///: BEGIN:ONLY_INCLUDE_IF(snaps) import { SnapsSettingsList } from '../../Views/Snaps/SnapsSettingsList'; -import { SnapSettings } from '../../Views/Snaps/SnapSettings'; +import { + SnapSettings, + ALLOWED_CAPABILITIES as SNAPS_SETTINGS_ROUTE_ALLOWED_CAPABILITIES, +} from '../../Views/Snaps/SnapSettings'; import { CAN_INSTALL_THIRD_PARTY_SNAPS } from '../../../constants/snaps'; ///: END:ONLY_INCLUDE_IF import Routes from '../../../constants/navigation/Routes'; @@ -168,6 +171,7 @@ import BenefitFullView from '../../UI/Rewards/Views/BenefitFullView'; import BenefitsFullView from '../../UI/Rewards/Views/BenefitsFullView'; import { getDeFiProtocolPositionDetailsNavbarOptions } from '../../UI/Navbar'; import MoneyTabPressTracker from '../../UI/Money/components/MoneyTabPressTracker'; +import { withMessenger } from '../../../messengers/helpers/route-messenger-helpers'; const Stack = createStackNavigator(); const NativeStack = createNativeStackNavigator(); @@ -430,6 +434,10 @@ const ExploreHome = () => { }; ///: BEGIN:ONLY_INCLUDE_IF(snaps) +const SnapSettingsWithMessenger = withMessenger(SnapSettings, { + capabilities: SNAPS_SETTINGS_ROUTE_ALLOWED_CAPABILITIES, +}); + const SnapsSettingsStack = () => { const { colors } = useTheme(); return ( @@ -445,7 +453,7 @@ const SnapsSettingsStack = () => { /> ); diff --git a/app/components/Views/Root/index.tsx b/app/components/Views/Root/index.tsx index c9e2276fc13..6397fb43c70 100644 --- a/app/components/Views/Root/index.tsx +++ b/app/components/Views/Root/index.tsx @@ -1,4 +1,4 @@ -import React, { useEffect, useState } from 'react'; +import React, { useEffect, useRef, useState } from 'react'; import { Provider } from 'react-redux'; import { PersistGate } from 'redux-persist/lib/integration/react'; import { store, persistor } from '../../../store'; @@ -24,6 +24,11 @@ import { ReducedMotionConfig, ReduceMotion } from 'react-native-reanimated'; import { QueryClientProvider } from '@tanstack/react-query'; import reactQueryService from '../../../core/ReactQueryService'; import { HardwareWalletProvider } from '../../../core/HardwareWallet'; +import { UIMessengerProvider } from '../../../contexts/ui-messenger'; +import { + createUIMessenger, + UIMessenger, +} from '../../../messengers/ui-messenger'; /** * Top level of the component hierarchy @@ -32,6 +37,14 @@ import { HardwareWalletProvider } from '../../../core/HardwareWallet'; const Root = ({ foxCode }: RootProps) => { const [isStoreLoading, setIsStoreLoading] = useState(true); + // We use a ref to make sure the UI messenger is only created once. + const uiMessengerRef = useRef(null); + if (!uiMessengerRef.current) { + uiMessengerRef.current = createUIMessenger(); + } + + const uiMessenger = uiMessengerRef.current; + /** * Wait for store to be initialized in Detox tests * Note: This is a workaround for an issue with Detox where the store is not initialized @@ -86,12 +99,14 @@ const Root = ({ foxCode }: RootProps) => { - - - - - - + + + + + + + + diff --git a/app/components/Views/Snaps/SnapSettings/SnapSettings.tsx b/app/components/Views/Snaps/SnapSettings/SnapSettings.tsx index fb6784fd744..47e3ace6884 100644 --- a/app/components/Views/Snaps/SnapSettings/SnapSettings.tsx +++ b/app/components/Views/Snaps/SnapSettings/SnapSettings.tsx @@ -41,6 +41,8 @@ import { selectInternalAccounts } from '../../../../selectors/accountsController import { InternalAccount } from '@metamask/keyring-internal-api'; import Logger from '../../../../util/Logger'; import { areAddressesEqual } from '../../../../util/address'; +import { RouteMessengerInstance } from './messenger'; +import { useMessenger } from '../../../../hooks/useMessenger'; interface SnapSettingsProps { snap: Snap; } @@ -52,6 +54,7 @@ const SnapSettings = () => { const { styles, theme } = useStyles(stylesheet, {}); const { colors } = theme; const navigation = useNavigation(); + const messenger = useMessenger(); const { snap } = useParams(); const permissionsState = useSelector(selectPermissionControllerState); @@ -104,8 +107,7 @@ const SnapSettings = () => { }, []); const removeSnap = useCallback(async () => { - const { SnapController } = Engine.context; - await SnapController.removeSnap(snap.id); + await messenger.call('SnapController:removeSnap', snap.id); if (isKeyringSnap && keyringAccounts.length > 0) { try { @@ -120,7 +122,7 @@ const SnapSettings = () => { } } navigation.goBack(); - }, [isKeyringSnap, keyringAccounts, navigation, snap.id]); + }, [isKeyringSnap, keyringAccounts, navigation, messenger, snap.id]); const handleRemoveSnap = useCallback(() => { if (isKeyringSnap && keyringAccounts.length > 0) { diff --git a/app/components/Views/Snaps/SnapSettings/index.ts b/app/components/Views/Snaps/SnapSettings/index.ts index 3b7fda3a8a2..87f3a3e9d56 100644 --- a/app/components/Views/Snaps/SnapSettings/index.ts +++ b/app/components/Views/Snaps/SnapSettings/index.ts @@ -1,4 +1,5 @@ ///: BEGIN:ONLY_INCLUDE_IF(snaps) /* eslint-disable import-x/prefer-default-export */ export { default as SnapSettings } from './SnapSettings'; +export { ALLOWED_CAPABILITIES } from './messenger'; ///: END:ONLY_INCLUDE_IF diff --git a/app/components/Views/Snaps/SnapSettings/messenger.ts b/app/components/Views/Snaps/SnapSettings/messenger.ts new file mode 100644 index 00000000000..d55b38779c0 --- /dev/null +++ b/app/components/Views/Snaps/SnapSettings/messenger.ts @@ -0,0 +1,11 @@ +import { defineAllowedRouteCapabilities } from '../../../../messengers/helpers/route-messenger-helpers'; +import type { RouteMessengerFromCapabilities } from '../../../../messengers/route-messenger'; + +export const ALLOWED_CAPABILITIES = defineAllowedRouteCapabilities({ + actions: ['SnapController:removeSnap'], + events: [], +}); + +export type RouteMessengerInstance = RouteMessengerFromCapabilities< + typeof ALLOWED_CAPABILITIES +>; diff --git a/app/components/Views/Snaps/SnapSettings/test/SnapSettings.test.tsx b/app/components/Views/Snaps/SnapSettings/test/SnapSettings.test.tsx index 36c5492ab0e..f0029dad4d8 100644 --- a/app/components/Views/Snaps/SnapSettings/test/SnapSettings.test.tsx +++ b/app/components/Views/Snaps/SnapSettings/test/SnapSettings.test.tsx @@ -30,6 +30,7 @@ import { KEYRING_SNAP_REMOVAL_WARNING_CONTINUE, KEYRING_SNAP_REMOVAL_WARNING_TEXT_INPUT, } from '../../KeyringSnapRemovalWarning/KeyringSnapRemovalWarning.constants'; +import { createMockRouteMessenger } from '../../../../../util/test/mock-route-messenger'; const MOCK_ACCOUNTS_CONTROLLER_STATE = createMockAccountsControllerState([ MOCK_ADDRESS_1, @@ -219,9 +220,6 @@ jest.mock('../../../../../core/Engine', () => { }), removeAccount: jest.fn(), context: { - SnapController: { - removeSnap: jest.fn(), - }, KeyringController: { state: { keyrings: [ @@ -252,11 +250,16 @@ describe('SnapSettings with non keyring snap', () => { }); it('renders correctly', () => { + const routeMessenger = createMockRouteMessenger({ + 'SnapController:removeSnap': jest.fn(), + }); + const { getAllByTestId, getByTestId } = renderWithProvider( , { // eslint-disable-next-line @typescript-eslint/no-explicit-any state: initialState as any, + routeMessenger, }, ); @@ -275,9 +278,14 @@ describe('SnapSettings with non keyring snap', () => { }); it('calls navigation.goBack when header back button is pressed', () => { + const routeMessenger = createMockRouteMessenger({ + 'SnapController:removeSnap': jest.fn(), + }); + const { getByTestId } = renderWithProvider(, { // eslint-disable-next-line @typescript-eslint/no-explicit-any state: initialState as any, + routeMessenger, }); fireEvent(getByTestId(SNAP_SETTINGS_BACK_BUTTON), 'onPress'); @@ -285,14 +293,22 @@ describe('SnapSettings with non keyring snap', () => { }); it('remove snap and goes back when Remove button is pressed', async () => { + const routeMessenger = createMockRouteMessenger({ + 'SnapController:removeSnap': jest.fn(), + }); + + jest.spyOn(routeMessenger, 'call'); + const { getByTestId } = renderWithProvider(, { // eslint-disable-next-line @typescript-eslint/no-explicit-any state: initialState as any, + routeMessenger, }); const removeButton = getByTestId(SNAP_SETTINGS_REMOVE_BUTTON); fireEvent(removeButton, 'onPress'); - expect(Engine.context.SnapController.removeSnap).toHaveBeenCalledWith( + expect(routeMessenger.call).toHaveBeenCalledWith( + 'SnapController:removeSnap', 'npm:@chainsafe/filsnap', ); await waitFor(() => { @@ -407,9 +423,14 @@ describe('SnapSettings with keyring snap', () => { }; it('renders KeyringSnapRemovalWarning when removing a keyring snap', async () => { + const routeMessenger = createMockRouteMessenger({ + 'SnapController:removeSnap': jest.fn(), + }); + const { getByTestId } = renderWithProvider(, { // eslint-disable-next-line @typescript-eslint/no-explicit-any state: initialStateWithKeyringSnap as any, + routeMessenger, }); // Needed to allow for useEffect to run @@ -423,10 +444,17 @@ describe('SnapSettings with keyring snap', () => { }); }); - it('calls Engine.context.SnapController and Engine.removeAccount when removing a keyring snap with accounts', async () => { + it('calls `SnapController:removeSnap` when removing a keyring snap with accounts', async () => { + const routeMessenger = createMockRouteMessenger({ + 'SnapController:removeSnap': jest.fn(), + }); + + jest.spyOn(routeMessenger, 'call'); + const { getByTestId } = renderWithProvider(, { // eslint-disable-next-line @typescript-eslint/no-explicit-any state: initialStateWithKeyringSnap as any, + routeMessenger, }); // Needed to allow for useEffect to run @@ -456,7 +484,8 @@ describe('SnapSettings with keyring snap', () => { // Step 5: Verify that the removal functions are called await waitFor(() => { - expect(Engine.context.SnapController.removeSnap).toHaveBeenCalledWith( + expect(routeMessenger.call).toHaveBeenCalledWith( + 'SnapController:removeSnap', mockKeyringSnapId, ); expect(Engine.removeAccount).toHaveBeenCalledTimes(2); diff --git a/app/contexts/route-messenger.ts b/app/contexts/route-messenger.ts new file mode 100644 index 00000000000..796fdf0efc8 --- /dev/null +++ b/app/contexts/route-messenger.ts @@ -0,0 +1,28 @@ +import { createContext, useContext } from 'react'; + +import { RouteMessenger } from '../messengers/route-messenger'; + +/** + * Context that holds the messenger for the current route. + * + * @see {@link RouteWithMessenger} + */ +export const RouteMessengerContext = createContext(null); + +/** + * Hook to access the messenger for the current route from context. + * + * @returns The route messenger in context. + * @throws If the route messenger has not been set. + */ +export function useRouteMessenger(): RouteMessenger { + const messenger = useContext(RouteMessengerContext); + + if (!messenger) { + throw new Error( + 'useRouteMessenger must be used within a route messenger context.', + ); + } + + return messenger; +} diff --git a/app/contexts/ui-messenger.tsx b/app/contexts/ui-messenger.tsx new file mode 100644 index 00000000000..ba156d48f05 --- /dev/null +++ b/app/contexts/ui-messenger.tsx @@ -0,0 +1,48 @@ +import React, { createContext, ReactNode, useContext } from 'react'; +import { UIMessenger } from '../messengers/ui-messenger'; + +/** + * Context that holds the UI messenger. + */ +export const UIMessengerContext = createContext(null); + +/** + * Provides the UI messenger to child components via context. + * + * @param args - The arguments to this function. + * @param args.value - The UI messenger to load into context. + * @param args.children - The components to wrap. + */ +export const UIMessengerProvider = ({ + value, + children, +}: { + value: UIMessenger; + children: ReactNode; +}) => ( + + {children} + +); + +/** + * Hook to access the UI messenger from context. + * + * Used to derive route-level messengers. Do not use this hook directly, + * only use it to define route-level messenger hooks. + * + * @returns The UI messenger in context. + * @throws If the UI messenger is not available (e.g., hook is used outside of + * the provider). + */ +export function useUIMessenger(): UIMessenger { + const messenger = useContext(UIMessengerContext); + + if (!messenger) { + throw new Error( + 'The `useUIMessenger` hook must be used within a `UIMessengerProvider`.', + ); + } + + return messenger; +} diff --git a/app/core/Engine/types.ts b/app/core/Engine/types.ts index 17b9c03a296..93ea56f0814 100644 --- a/app/core/Engine/types.ts +++ b/app/core/Engine/types.ts @@ -522,7 +522,7 @@ type SnapsGlobalEvents = | PhishingControllerEvents; ///: END:ONLY_INCLUDE_IF -type GlobalActions = +export type GlobalActions = ///: BEGIN:ONLY_INCLUDE_IF(sample-feature) | SamplePetnamesControllerActions ///: END:ONLY_INCLUDE_IF @@ -613,7 +613,7 @@ type GlobalActions = | ChompApiServiceActions | MoneyAccountUpgradeControllerActions; -type GlobalEvents = +export type GlobalEvents = ///: BEGIN:ONLY_INCLUDE_IF(sample-feature) | SamplePetnamesControllerEvents ///: END:ONLY_INCLUDE_IF diff --git a/app/hooks/useMessenger.ts b/app/hooks/useMessenger.ts new file mode 100644 index 00000000000..335719e1092 --- /dev/null +++ b/app/hooks/useMessenger.ts @@ -0,0 +1,43 @@ +import { useRouteMessenger } from '../contexts/route-messenger'; +import type { RouteMessenger } from '../messengers/route-messenger'; + +/** + * Use this hook to access the messenger that corresponds to the current route. + * You can then use the messenger to call actions and subscribe to events that + * the route has permission to access. + * + * Supply a {@link RouteMessenger} type as the type parameter to narrow the + * returned messenger to only the actions and events you need. + * + * @returns The route messenger cast to the requested messenger type. + * @example + * ```typescript + * type NetworkListMessenger = RouteMessenger< + * 'NetworkController:addNetwork', + * never + * >; + * + * function NetworkList() { + * const messenger = useMessenger(); + * + * const onAddNetworkClick = async () => { + * await messenger.call('NetworkController:addNetwork', ...); + * } + * + * return ( + * + * ): + * } + * ``` + */ +export function useMessenger< + RouteMessengerInstance extends RouteMessenger, +>(): RouteMessengerInstance { + const routeMessenger = useRouteMessenger(); + + // The context only tells us we have *some* kind of route messenger, but not + // what capabilities it has. We trust the caller's type parameter here — the + // route configuration is responsible for ensuring the messenger actually + // supports the requested actions/events. + return routeMessenger as unknown as RouteMessengerInstance; +} diff --git a/app/messengers/configs/helpers.ts b/app/messengers/configs/helpers.ts new file mode 100644 index 00000000000..aa33887b1b5 --- /dev/null +++ b/app/messengers/configs/helpers.ts @@ -0,0 +1,26 @@ +import type { ValidateElements } from '../helpers/route-messenger-helpers'; +import { GlobalActions, GlobalEvents } from '../../core/Engine'; + +/** + * Helper function to define the excluded capabilities for a messenger. This is + * primarily a type-level helper to ensure that the excluded capabilities are + * valid and to get better type inference for the excluded capabilities. + * + * @param capabilities - The capabilities to exclude, which must be valid action + * and event types for the `RootMessenger`. + * @param capabilities.actions - The action types to exclude, which must be + * valid action types for the `RootMessenger`. + * @param capabilities.events - The event types to exclude, which must be valid + * event types for the `RootMessenger`. + * @returns The given capabilities, typed as the specific action and event types + * that were excluded. + */ +export function defineExcludedCapabilities< + const ActionTypes extends readonly string[], + const EventTypes extends readonly string[], +>(capabilities: { + actions: ValidateElements; + events: ValidateElements; +}): { actions: ActionTypes; events: EventTypes } { + return capabilities as { actions: ActionTypes; events: EventTypes }; +} diff --git a/app/messengers/configs/index.ts b/app/messengers/configs/index.ts new file mode 100644 index 00000000000..110c0b69b2a --- /dev/null +++ b/app/messengers/configs/index.ts @@ -0,0 +1,22 @@ +/* eslint-disable import-x/no-namespace */ + +import * as keyringController from './keyring-controller'; +import { GlobalActions, GlobalEvents } from '../../core/Engine'; + +interface MessengerExclusions { + // This is named like this since it's intended to be defined as a top-level + // property of a messenger configuration. + // eslint-disable-next-line @typescript-eslint/naming-convention + EXCLUDED_CAPABILITIES: { + actions: readonly GlobalActions['type'][]; + events: readonly GlobalEvents['type'][]; + }; +} + +// If you need to exclude an action or event from being accessible in the UI, +// add a file to this directory and then add the module to this list. +// Note: A file does not need to exist for every controller or service, just +// those that need to exclude certain actions and/or events. +export const MESSENGERS_WITH_EXCLUSIONS = [ + keyringController, +] satisfies MessengerExclusions[]; diff --git a/app/messengers/configs/keyring-controller.ts b/app/messengers/configs/keyring-controller.ts new file mode 100644 index 00000000000..9acd56f94a8 --- /dev/null +++ b/app/messengers/configs/keyring-controller.ts @@ -0,0 +1,17 @@ +import { defineExcludedCapabilities } from './helpers'; + +// By default, all actions and events are allowed. If there an action or event +// you think should NOT be accessible from the UI, update this. +export const EXCLUDED_CAPABILITIES = defineExcludedCapabilities({ + actions: [ + 'KeyringController:addNewKeyring', + 'KeyringController:getKeyringsByType', + 'KeyringController:getKeyringForAccount', + 'KeyringController:withController', + 'KeyringController:withKeyring', + 'KeyringController:withKeyringUnsafe', + 'KeyringController:withKeyringV2', + 'KeyringController:withKeyringV2Unsafe', + ], + events: [], +}); diff --git a/app/messengers/helpers/route-messenger-helpers.test.tsx b/app/messengers/helpers/route-messenger-helpers.test.tsx new file mode 100644 index 00000000000..5af693798ba --- /dev/null +++ b/app/messengers/helpers/route-messenger-helpers.test.tsx @@ -0,0 +1,65 @@ +import React from 'react'; +import { withMessenger } from './route-messenger-helpers'; + +describe('withMessenger', () => { + const FooComponent = () =>
Foo
; + + it('returns a Route component with the expected shape', () => { + const Route = withMessenger(FooComponent, { + capabilities: { + actions: ['SnapController:installSnaps'], + events: ['SnapController:snapInstalled'], + }, + }); + + expect( + , + ).toMatchInlineSnapshot(` + + `); + }); +}); diff --git a/app/messengers/helpers/route-messenger-helpers.tsx b/app/messengers/helpers/route-messenger-helpers.tsx new file mode 100644 index 00000000000..387a2ec1408 --- /dev/null +++ b/app/messengers/helpers/route-messenger-helpers.tsx @@ -0,0 +1,125 @@ +import React, { ComponentType, FunctionComponent } from 'react'; +import { UIMessengerActions, UIMessengerEvents } from '../ui-messenger'; +import { RouteWithMessenger } from '../route-with-messenger'; +import { + NavigationProp, + EventMapBase, + RouteProp, + ParamListBase, + NavigationState, +} from '@react-navigation/native'; + +/** + * Validate that each element of a tuple is a member of the given union, + * producing a type error for elements that aren't. + */ +export type ValidateElements< + Elements extends readonly string[], + AllowedElements extends string, +> = { + [K in keyof Elements]: Elements[K] extends AllowedElements + ? Elements[K] + : AllowedElements; +}; + +/** + * Helper function to define the allowed capabilities for a route messenger. + * This is primarily a type-level helper to ensure that the allowed capabilities + * are valid UI messenger capabilities and to get better type inference for the + * allowed capabilities. + * + * @param capabilities - The capabilities to allow, which must be valid action + * and event types for the `UIMessenger`. + * @param capabilities.actions - The action types to allow, which must be + * valid action types for the `UIMessenger`. + * @param capabilities.events - The event types to allow, which must be valid + * event types for the `UIMessenger`. + * @returns The given capabilities, typed as the specific action and event types + * that were allowed. + */ +export function defineAllowedRouteCapabilities< + const ActionTypes extends string[], + const EventTypes extends string[], +>(capabilities: { + actions: ValidateElements; + events: ValidateElements; +}): { actions: ActionTypes; events: EventTypes } { + return capabilities as { actions: ActionTypes; events: EventTypes }; +} + +interface WithMessengerOptions { + capabilities: { + actions?: UIMessengerActions['type'][]; + events?: UIMessengerEvents['type'][]; + }; +} + +interface RouteProps< + ParamList extends ParamListBase, + RouteName extends keyof ParamList, + NavigatorID extends string | undefined = undefined, + State extends NavigationState = NavigationState, + ScreenOptions extends {} = {}, + EventMap extends EventMapBase = {}, +> { + route: RouteProp; + navigation: NavigationProp< + ParamList, + RouteName, + NavigatorID, + State, + ScreenOptions, + EventMap + >; +} + +/** + * Create a route object with a {@link RouteWithMessenger} element that provides + * a route messenger with the specified capabilities. + * + * @param Component - The component to render for this route. This will be + * wrapped in a {@link RouteWithMessenger} component that provides the route + * messenger. + * @param options - Options bag. + * @param options.capabilities - Capabilities to delegate from the UI messenger + * to the route messenger. + */ +export function withMessenger< + ParamList extends ParamListBase, + RouteName extends keyof ParamList, + NavigatorID extends string | undefined = undefined, + State extends NavigationState = NavigationState, + ScreenOptions extends {} = {}, + EventMap extends EventMapBase = {}, +>( + Component: ComponentType< + RouteProps< + ParamList, + RouteName, + NavigatorID, + State, + ScreenOptions, + EventMap + > + >, + { capabilities }: WithMessengerOptions, +): FunctionComponent< + RouteProps +> { + return function RouteWithMessengerElement( + props: RouteProps< + ParamList, + RouteName, + NavigatorID, + State, + ScreenOptions, + EventMap + >, + ) { + return ( + + + + ); + }; +} diff --git a/app/messengers/route-messenger.test.ts b/app/messengers/route-messenger.test.ts new file mode 100644 index 00000000000..d6ac2abf80d --- /dev/null +++ b/app/messengers/route-messenger.test.ts @@ -0,0 +1,38 @@ +// eslint-disable-next-line import-x/no-namespace +import * as messengerModule from '@metamask/messenger'; +import { + createRouteMessenger, + getRouteMessengerNamespace, +} from './route-messenger'; + +describe('getRouteMessengerNamespace', () => { + it.each([ + ['SomePath', 'SomePathRoute'], + ['AnotherExample', 'AnotherExampleRoute'], + ['', 'Route'], + ])( + 'derives the correct namespace from the path "%s"', + (path, expectedNamespace) => { + expect(getRouteMessengerNamespace(path)).toBe(expectedNamespace); + }, + ); +}); + +describe('createRouteMessenger', () => { + it('creates a route messenger with the correct namespace', () => { + const OriginalMessenger = messengerModule.Messenger; + const MessengerSpy = jest + .spyOn(messengerModule, 'Messenger') + .mockImplementation( + (...args: ConstructorParameters) => + new OriginalMessenger(...args), + ); + + createRouteMessenger({ path: 'SomePath' }); + + expect(MessengerSpy).toHaveBeenCalledWith({ + namespace: 'SomePathRoute', + captureException: expect.any(Function), + }); + }); +}); diff --git a/app/messengers/route-messenger.ts b/app/messengers/route-messenger.ts new file mode 100644 index 00000000000..c96b8c4482f --- /dev/null +++ b/app/messengers/route-messenger.ts @@ -0,0 +1,77 @@ +import { Messenger } from '@metamask/messenger'; +import type { UIMessengerActions, UIMessengerEvents } from './ui-messenger'; +import { captureException } from '@sentry/react-native'; + +/** + * Helper type to derive a route messenger type from a capabilities object. + */ +export type RouteMessengerFromCapabilities< + CapabilitiesType extends { + actions?: UIMessengerActions['type'][]; + events?: UIMessengerEvents['type'][]; + }, +> = RouteMessenger< + CapabilitiesType extends { actions: (infer ActionTypes)[] } + ? ActionTypes + : never, + CapabilitiesType extends { events: (infer EventTypes)[] } ? EventTypes : never +>; + +/** + * A messenger that represents a route. + * + * This type is intentionally generic (a bit unusual for messenger "instance" + * types) because each route gets its own messenger (the "route messenger" isn't + * a singleton as is the case for controllers and services). + */ +export type RouteMessenger< + ActionTypes extends UIMessengerActions['type'] = UIMessengerActions['type'], + EventTypes extends UIMessengerEvents['type'] = UIMessengerEvents['type'], +> = Messenger< + `${string}Route`, + Extract, + Extract +>; + +/** + * Derive a route messenger namespace from a route path. Assumes the path is + * already formatted as PascalCase (e.g. 'SomePath' rather than '/some/path'). + * + * @example + * ```typescript + * getRouteMessengerNamespace('SomePath'); // 'SomePathRoute' + * getRouteMessengerNamespace('AnotherExample'); // 'AnotherExampleRoute' + * getRouteMessengerNamespace(''); // 'Route' + * ``` + * @param path - The route path to derive the namespace from. + * @returns A namespace string derived from the route path. + */ +export function getRouteMessengerNamespace( + path: string = '', +): `${string}Route` { + return `${path}Route`; +} + +/** + * Create a messenger for a route. + * + * This is used when defining routes (that is, each route gets its own + * messenger). Delegation of actions and events is handled separately by the + * caller (typically via `RouteWithMessenger`). + * + * @param args - Arguments for this function. + * @param args.path - The path of the route. This is used for debugging purposes + * and to ensure that the route messenger's namespace is unique across routes. + * @returns A messenger for the route. + */ +export function createRouteMessenger< + ActionTypes extends UIMessengerActions['type'], + EventTypes extends UIMessengerEvents['type'], +>({ path }: { path: string }): RouteMessenger { + return new Messenger<`${string}Route`, UIMessengerActions, UIMessengerEvents>( + { + namespace: getRouteMessengerNamespace(path), + captureException, + }, + ); +} diff --git a/app/messengers/route-with-messenger.test.tsx b/app/messengers/route-with-messenger.test.tsx new file mode 100644 index 00000000000..51df80e4551 --- /dev/null +++ b/app/messengers/route-with-messenger.test.tsx @@ -0,0 +1,94 @@ +import React from 'react'; +import { act } from '@testing-library/react-native'; +import { View } from 'react-native'; +// eslint-disable-next-line import-x/no-namespace +import * as routeMessengerModule from '../messengers/route-messenger'; +import { RouteWithMessenger } from './route-with-messenger'; +import renderWithProvider from '../util/test/renderWithProvider'; +import { createMockUIMessenger } from '../util/test/mock-ui-messenger'; +import type { UIMessengerActions, UIMessengerEvents } from './ui-messenger'; + +const CAPABILITIES = { + actions: [ + 'SnapController:installSnaps' as unknown as UIMessengerActions['type'], + ], + events: [ + 'SnapController:snapInstalled' as unknown as UIMessengerEvents['type'], + ], +}; + +describe('RouteWithMessenger', () => { + it('renders children once delegation is complete', async () => { + const uiMessenger = createMockUIMessenger(); + jest.spyOn(uiMessenger, 'delegate').mockResolvedValue(undefined); + + const { findByTestId } = renderWithProvider( + + + , + { uiMessenger }, + ); + + expect(await findByTestId('child')).toBeOnTheScreen(); + }); + + it('delegates capabilities to the route messenger on mount', async () => { + const uiMessenger = createMockUIMessenger(); + const delegateSpy = jest + .spyOn(uiMessenger, 'delegate') + .mockResolvedValue(undefined); + + const createRouteMessengerSpy = jest.spyOn( + routeMessengerModule, + 'createRouteMessenger', + ); + + const { findByTestId } = renderWithProvider( + + + , + { uiMessenger }, + ); + + // Wait for delegation to complete. + await findByTestId('content'); + + expect(createRouteMessengerSpy).toHaveBeenCalledWith({ path: 'SomePath' }); + expect(delegateSpy).toHaveBeenCalledWith( + expect.objectContaining({ + actions: CAPABILITIES.actions, + events: CAPABILITIES.events, + }), + ); + }); + + it('revokes delegation when the component unmounts', async () => { + const uiMessenger = createMockUIMessenger(); + jest.spyOn(uiMessenger, 'delegate').mockResolvedValue(undefined); + + const revokeSpy = jest + .spyOn(uiMessenger, 'revoke') + .mockResolvedValue(undefined); + + const { findByTestId, unmount } = renderWithProvider( + + + , + { uiMessenger }, + ); + + // Wait for delegation to complete before unmounting. + await findByTestId('content'); + + await act(async () => { + unmount(); + }); + + expect(revokeSpy).toHaveBeenCalledWith( + expect.objectContaining({ + actions: CAPABILITIES.actions, + events: CAPABILITIES.events, + }), + ); + }); +}); diff --git a/app/messengers/route-with-messenger.tsx b/app/messengers/route-with-messenger.tsx new file mode 100644 index 00000000000..bb2679aa706 --- /dev/null +++ b/app/messengers/route-with-messenger.tsx @@ -0,0 +1,84 @@ +import React, { ReactNode, useEffect, useRef, useState } from 'react'; +import { UIMessengerActions, UIMessengerEvents } from './ui-messenger'; +import { RouteMessengerContext } from '../contexts/route-messenger'; +import { useUIMessenger } from '../contexts/ui-messenger'; +import { createRouteMessenger, type RouteMessenger } from './route-messenger'; +import { captureException } from '@sentry/react-native'; + +/** + * Utility component which creates a messenger representing a route and + * provides it to children via context. + * + * Do not use this component directly. Instead, use the `withMessenger` HOC to + * wrap route components, which will render this component internally. + * + * @param props - Component props. + * @param props.path - The path of the route. This is used for debugging + * purposes and to ensure that the route messenger's namespace is unique across + * routes. + * @param props.capabilities - Capabilities to delegate to the route messenger. + * This must be a stable object (i.e. it should be memoized if defined inline) + * to avoid unnecessary re-delegation on every render. + * @param props.capabilities.actions - Action types to delegate to the route + * messenger. + * @param props.capabilities.events - Event types to delegate to the route + * messenger. + * @param props.children - Child components. + */ +export const RouteWithMessenger = ({ + path, + capabilities, + children, +}: { + path: string; + capabilities: { + actions?: UIMessengerActions['type'][]; + events?: UIMessengerEvents['type'][]; + }; + children: ReactNode; +}) => { + const uiMessenger = useUIMessenger(); + const routeMessengerRef = useRef(null); + const [isDelegated, setDelegated] = useState(false); + + // `useMemo` doesn't work here because `capabilities` is an object, so we use + // a ref instead to ensure that we only create the route messenger once. + if (!routeMessengerRef.current) { + routeMessengerRef.current = createRouteMessenger({ path }); + } + + useEffect(() => { + const messenger = routeMessengerRef.current; + if (!messenger) { + return; + } + + const { actions, events } = capabilities; + uiMessenger + .delegate({ messenger, actions, events }) + .then(() => setDelegated(true)) + .catch(captureException); + + return () => { + uiMessenger + .revoke({ messenger, actions, events }) + .then(() => setDelegated(false)) + .catch(captureException); + }; + }, [uiMessenger, capabilities]); + + const routeMessenger = routeMessengerRef.current; + + // Wait for delegation to complete before rendering children, to ensure that + // the route messenger has access to the delegated capabilities when children + // are rendered. + if (!isDelegated) { + return null; + } + + return ( + + {children} + + ); +}; diff --git a/app/messengers/ui-messenger.test.ts b/app/messengers/ui-messenger.test.ts new file mode 100644 index 00000000000..c8b53563092 --- /dev/null +++ b/app/messengers/ui-messenger.test.ts @@ -0,0 +1,298 @@ +import { + Messenger, + MOCK_ANY_NAMESPACE, + type MockAnyNamespace, +} from '@metamask/messenger'; +import { + UIMessenger, + type UIMessengerActions, + type UIMessengerEvents, +} from './ui-messenger'; +import Engine from '../core/Engine'; + +jest.mock('../core/Engine', () => ({ + controllerMessenger: { + call: jest.fn(), + subscribe: jest.fn(), + unsubscribe: jest.fn(), + }, +})); + +/** + * Create a delegatee messenger for testing. Uses MOCK_ANY_NAMESPACE to avoid + * namespace restrictions. + */ +function createDelegatee() { + return new Messenger( + { namespace: MOCK_ANY_NAMESPACE }, + ); +} + +describe('UIMessenger', () => { + let uiMessenger: UIMessenger; + let mockCall: jest.Mock; + let mockSubscribe: jest.Mock; + let mockUnsubscribe: jest.Mock; + + beforeEach(() => { + jest.clearAllMocks(); + uiMessenger = new UIMessenger(); + mockCall = jest.mocked(Engine.controllerMessenger.call); + mockSubscribe = jest.mocked(Engine.controllerMessenger.subscribe); + mockUnsubscribe = jest.mocked(Engine.controllerMessenger.unsubscribe); + }); + + describe('delegate', () => { + describe('actions', () => { + it('registers a handler on the delegatee that routes to the background', async () => { + const delegatee = createDelegatee(); + mockCall.mockReturnValue('result'); + + await uiMessenger.delegate({ + actions: ['SnapController:installSnaps'], + messenger: delegatee, + }); + + const result = await delegatee.call( + 'SnapController:installSnaps', + 'metamask', + {}, + ); + + expect(mockCall).toHaveBeenCalledWith( + 'SnapController:installSnaps', + 'metamask', + {}, + ); + expect(result).toBe('result'); + }); + + it('throws if an excluded action is called', async () => { + const delegatee = createDelegatee(); + + await uiMessenger.delegate({ + // @ts-expect-error: Excluded action for testing purposes. + actions: ['KeyringController:addNewKeyring'], + messenger: delegatee, + }); + + // @ts-expect-error: Excluded action for testing purposes. + expect(() => delegatee.call('KeyringController:addNewKeyring')).toThrow( + `The action "KeyringController:addNewKeyring" has not been exposed to the UI.`, + ); + }); + + it('throws if the same action is delegated to the same messenger twice', async () => { + const delegatee = createDelegatee(); + + await uiMessenger.delegate({ + actions: ['SnapController:installSnaps'], + messenger: delegatee, + }); + + await expect( + uiMessenger.delegate({ + actions: ['SnapController:installSnaps'], + messenger: delegatee, + }), + ).rejects.toThrow( + `The action "${'SnapController:installSnaps'}" has already been delegated to this messenger.`, + ); + }); + + it('allows the same action to be delegated to different messengers', async () => { + const delegatee1 = createDelegatee(); + const delegatee2 = createDelegatee(); + + await expect( + Promise.all([ + uiMessenger.delegate({ + actions: ['SnapController:installSnaps'], + messenger: delegatee1, + }), + uiMessenger.delegate({ + actions: ['SnapController:installSnaps'], + messenger: delegatee2, + }), + ]), + ).resolves.not.toThrow(); + }); + }); + + describe('events', () => { + it('subscribes to the event via Engine.controllerMessenger', async () => { + const delegatee = createDelegatee(); + + await uiMessenger.delegate({ + events: ['SnapController:snapInstalled'], + messenger: delegatee, + }); + + expect(mockSubscribe).toHaveBeenCalledWith( + 'SnapController:snapInstalled', + expect.any(Function), + ); + }); + + it('publishes background events to the delegatee', async () => { + const delegatee = createDelegatee(); + const handler = jest.fn(); + // eslint-disable-next-line @typescript-eslint/no-explicit-any + (delegatee as any).subscribe('SnapController:snapInstalled', handler); + + let capturedCallback!: (...args: unknown[]) => void; + mockSubscribe.mockImplementation( + (_event: string, callback: unknown) => { + capturedCallback = callback as (...args: unknown[]) => void; + }, + ); + + await uiMessenger.delegate({ + events: ['SnapController:snapInstalled'], + messenger: delegatee, + }); + + capturedCallback(); + + expect(handler).toHaveBeenCalled(); + }); + + it('throws if the same event is delegated to the same messenger twice', async () => { + const delegatee = createDelegatee(); + + await uiMessenger.delegate({ + events: ['SnapController:snapInstalled'], + messenger: delegatee, + }); + + await expect( + uiMessenger.delegate({ + events: ['SnapController:snapInstalled'], + messenger: delegatee, + }), + ).rejects.toThrow( + `The event 'SnapController:snapInstalled' has already been delegated to this messenger`, + ); + }); + + it('creates independent background subscriptions for different messengers', async () => { + const delegatee1 = createDelegatee(); + const delegatee2 = createDelegatee(); + + await uiMessenger.delegate({ + events: ['SnapController:snapInstalled'], + messenger: delegatee1, + }); + await uiMessenger.delegate({ + events: ['SnapController:snapInstalled'], + messenger: delegatee2, + }); + + expect(mockSubscribe).toHaveBeenCalledTimes(2); + }); + }); + }); + + describe('revoke', () => { + describe('actions', () => { + it('unregisters the action handler from the delegatee', async () => { + const delegatee = createDelegatee(); + + await uiMessenger.delegate({ + actions: ['SnapController:installSnaps'], + messenger: delegatee, + }); + await uiMessenger.revoke({ + actions: ['SnapController:installSnaps'], + messenger: delegatee, + }); + + expect(() => + delegatee.call('SnapController:installSnaps', 'metamask', {}), + ).toThrow(); + }); + + it('silently ignores actions that have not been delegated', async () => { + const delegatee = createDelegatee(); + + await expect( + uiMessenger.revoke({ + actions: ['SnapController:installSnaps'], + messenger: delegatee, + }), + ).resolves.not.toThrow(); + }); + + it('allows re-delegation after revoking', async () => { + const delegatee = createDelegatee(); + + await uiMessenger.delegate({ + actions: ['SnapController:installSnaps'], + messenger: delegatee, + }); + await uiMessenger.revoke({ + actions: ['SnapController:installSnaps'], + messenger: delegatee, + }); + + await expect( + uiMessenger.delegate({ + actions: ['SnapController:installSnaps'], + messenger: delegatee, + }), + ).resolves.not.toThrow(); + }); + }); + + describe('events', () => { + it('calls Engine.controllerMessenger.unsubscribe for the event', async () => { + const delegatee = createDelegatee(); + + await uiMessenger.delegate({ + events: ['SnapController:snapInstalled'], + messenger: delegatee, + }); + await uiMessenger.revoke({ + events: ['SnapController:snapInstalled'], + messenger: delegatee, + }); + + expect(mockUnsubscribe).toHaveBeenCalledWith( + 'SnapController:snapInstalled', + expect.any(Function), + ); + }); + + it('allows re-delegation after revoking', async () => { + const delegatee = createDelegatee(); + + await uiMessenger.delegate({ + events: ['SnapController:snapInstalled'], + messenger: delegatee, + }); + await uiMessenger.revoke({ + events: ['SnapController:snapInstalled'], + messenger: delegatee, + }); + + await expect( + uiMessenger.delegate({ + events: ['SnapController:snapInstalled'], + messenger: delegatee, + }), + ).resolves.not.toThrow(); + }); + + it('silently ignores events that have not been delegated', async () => { + const delegatee = createDelegatee(); + + await expect( + uiMessenger.revoke({ + events: ['SnapController:snapInstalled'], + messenger: delegatee, + }), + ).resolves.not.toThrow(); + }); + }); + }); +}); diff --git a/app/messengers/ui-messenger.ts b/app/messengers/ui-messenger.ts new file mode 100644 index 00000000000..aa74718945f --- /dev/null +++ b/app/messengers/ui-messenger.ts @@ -0,0 +1,323 @@ +import { + Messenger, + ActionConstraint, + ExtractEventPayload, + EventConstraint, + MessengerActions, + MessengerEvents, + ExtractActionParameters, + ExtractActionResponse, + ExtractEventHandler, +} from '@metamask/messenger'; +import type { Json } from '@metamask/utils'; +import { MESSENGERS_WITH_EXCLUSIONS } from './configs'; +import Engine, { GlobalActions, GlobalEvents } from '../core/Engine'; + +/** + * All actions are made asynchronous to match the implementation of the + * extension, which routes all actions through the background connection. This + * type makes a function asynchronous. + */ +type MakeAsynchronous unknown> = + InputFunction extends (...args: infer Args) => infer Return + ? (...args: Args) => Promise> + : never; + +/** + * All actions are made asynchronous to match the implementation of the + * extension, which routes all actions through the background connection. This + * type makes the given actions asynchronous. + */ +// Use a conditional type to ensure that TypeScript distributes a given union +// and we get a union back (this is essentially a `map`) +type MakeActionsAsynchronous = Action extends ActionConstraint + ? { type: Action['type']; handler: MakeAsynchronous } + : never; + +const EXCLUDED_ACTIONS = MESSENGERS_WITH_EXCLUSIONS.flatMap( + (config) => config.EXCLUDED_CAPABILITIES.actions, +); + +type MessengerWithExclusions = (typeof MESSENGERS_WITH_EXCLUSIONS)[number]; +type ExcludedActionTypes = + MessengerWithExclusions['EXCLUDED_CAPABILITIES']['actions'][number]; +type ExcludedEventTypes = + MessengerWithExclusions['EXCLUDED_CAPABILITIES']['events'][number]; + +/** + * Keeps only actions whose parameters and return value are JSON-serializable, + * to match the implementation of the extension. + */ +type WithJsonParams = + Action extends ActionConstraint + ? Parameters extends Json[] + ? Action + : never + : never; + +/** + * Keeps only events whose payload is JSON-serializable, to match the + * implementation of the extension. + */ +type WithJsonPayload = Event extends { + payload: infer P extends unknown[]; +} + ? P extends Json[] + ? Event + : never + : never; + +export type UIMessengerActions = MakeActionsAsynchronous< + WithJsonParams> +>; + +export type UIMessengerEvents = WithJsonPayload< + Exclude +>; + +/** + * A messenger that actions and/or events can be delegated to. + * + * This is a minimal type interface to avoid complex incompatibilities resulting + * from generics over invariant types. + */ +type DelegatedMessenger = Pick< + // The type is broadened to all actions/events because some messenger methods + // are contravariant over this type (`registerDelegatedActionHandler` and + // `publishDelegated` for example). If this type is narrowed to just the + // delegated actions/events, the types for event payload and action parameters + // would not be wide enough. + Messenger, + | '_internalPublishDelegated' + | '_internalRegisterDelegatedActionHandler' + | '_internalUnregisterDelegatedActionHandler' + | 'captureException' +>; + +// We intentionally don't extend the base `Messenger` class here because the UI +// messenger doesn't need to implement the full messenger API. It's only used as +// a bridge between the root messenger and the route messengers, and it +// delegates all of its actions and events to the root messenger by default, so +// it doesn't need to implement the full API itself. +export class UIMessenger { + /** + * The set of messengers we've delegated actions to, by action type. + */ + readonly #actionDelegationTargets = new Map< + UIMessengerActions['type'], + Set + >(); + + /** + * The set of messengers we've delegated events to and their event handlers, by event type. + */ + readonly #subscriptionDelegationTargets = new Map< + UIMessengerEvents['type'], + Map Promise> + >(); + + /** + * Delegate actions and events to a given messenger. + * + * Actions will be delegated by registering a handler on the delegatee + * messenger that submits a request to the background for the given action + * type and parameters. Events will be delegated by subscribing to the event + * on the root messenger and publishing it on the delegatee messenger when it + * occurs. + * + * @param options - The delegation options. + * @param options.actions - The action types to delegate. + * @param options.events - The event types to delegate. + * @param options.messenger - The messenger to delegate to. + * @throws If any action type has already been delegated to this messenger, or + * if any event type has already been delegated to this messenger. + */ + async delegate< + Delegatee extends Messenger, + DelegatedActions extends (MessengerActions['type'] & + UIMessengerActions['type'])[], + DelegatedEvents extends (MessengerEvents['type'] & + UIMessengerEvents['type'])[], + >({ + actions, + events, + messenger, + }: { + actions?: DelegatedActions; + events?: DelegatedEvents; + messenger: Delegatee; + }): Promise { + for (const actionType of actions ?? []) { + const delegatedActionHandler = ( + ...args: ExtractActionParameters< + MessengerActions & UIMessengerActions, + typeof actionType + > + ): ExtractActionResponse< + MessengerActions & UIMessengerActions, + typeof actionType + > => { + const excludedActions: string[] = EXCLUDED_ACTIONS; + if (excludedActions.includes(actionType)) { + throw new Error( + `The action "${actionType}" has not been exposed to the UI.`, + ); + } + + return Engine.controllerMessenger.call( + actionType, + ...args, + ) as ExtractActionResponse< + MessengerActions & UIMessengerActions, + typeof actionType + >; + }; + + let delegationTargets = this.#actionDelegationTargets.get(actionType); + if (!delegationTargets) { + delegationTargets = new Set(); + this.#actionDelegationTargets.set(actionType, delegationTargets); + } + + if (delegationTargets.has(messenger)) { + throw new Error( + `The action "${actionType}" has already been delegated to this messenger.`, + ); + } + + delegationTargets.add(messenger); + + // Intentionally calling a "deprecated" method here, since this is the + // internal API for delegation. + messenger._internalRegisterDelegatedActionHandler( + actionType, + delegatedActionHandler, + ); + } + + const promises = (events ?? []).map(async (eventType) => { + const untypedSubscriber = ( + ...payload: ExtractEventPayload< + MessengerEvents & UIMessengerEvents, + typeof eventType + > + ): void => { + // Intentionally calling a "deprecated" method here, since this is the + // internal API for delegation. + messenger._internalPublishDelegated(eventType, ...payload); + }; + + // Cast to get more specific subscriber type for this specific event. + // The types get collapsed here to the type union of all delegated + // events, rather than the single subscriber type corresponding to this + // event. + const subscriber = untypedSubscriber as ExtractEventHandler< + MessengerEvents & UIMessengerEvents, + typeof eventType + >; + + let delegatedEventSubscriptions = + this.#subscriptionDelegationTargets.get(eventType); + + if (!delegatedEventSubscriptions) { + delegatedEventSubscriptions = new Map(); + this.#subscriptionDelegationTargets.set( + eventType, + delegatedEventSubscriptions, + ); + } + + if (delegatedEventSubscriptions.has(messenger)) { + throw new Error( + `The event '${eventType}' has already been delegated to this messenger`, + ); + } + + Engine.controllerMessenger.subscribe(eventType, subscriber); + const unsubscribe = async () => + Engine.controllerMessenger.unsubscribe(eventType, subscriber); + + delegatedEventSubscriptions.set(messenger, unsubscribe); + }); + + await Promise.all(promises); + } + + /** + * Revoke delegation of actions and events to a given messenger. + * + * This is essentially the inverse of `delegate`, and will remove the + * delegated action handlers and event subscriptions that were added in + * `delegate`. + * + * @param options - The revocation options. + * @param options.actions - The action types to revoke delegation for. + * @param options.events - The event types to revoke delegation for. + * @param options.messenger - The messenger to revoke delegation from. + * @returns A promise that resolves when the revocation is complete. + */ + async revoke< + Delegatee extends Messenger, + DelegatedActions extends (MessengerActions['type'] & + UIMessengerActions['type'])[], + DelegatedEvents extends (MessengerEvents['type'] & + UIMessengerEvents['type'])[], + >({ + actions, + events, + messenger, + }: { + actions?: DelegatedActions; + events?: DelegatedEvents; + messenger: Delegatee; + }): Promise { + for (const actionType of actions ?? []) { + const delegationTargets = this.#actionDelegationTargets.get(actionType); + if (!delegationTargets?.has(messenger)) { + // Nothing to revoke. + continue; + } + + // Intentionally calling a "deprecated" method here, since this is the + // internal API for delegation. + messenger._internalUnregisterDelegatedActionHandler(actionType); + delegationTargets.delete(messenger); + + if (delegationTargets.size === 0) { + this.#actionDelegationTargets.delete(actionType); + } + } + + for (const eventType of events ?? []) { + const delegationTargets = + this.#subscriptionDelegationTargets.get(eventType); + + if (!delegationTargets) { + // Nothing to revoke. + continue; + } + + const unsubscribe = delegationTargets.get(messenger); + if (!unsubscribe) { + // Nothing to revoke. + continue; + } + + await unsubscribe(); + delegationTargets.delete(messenger); + + if (delegationTargets.size === 0) { + this.#subscriptionDelegationTargets.delete(eventType); + } + } + } +} + +/** + * Factory function to create a UI messenger instance. + * + * @returns A new instance of the UI messenger. + */ +export function createUIMessenger(): UIMessenger { + return new UIMessenger(); +} diff --git a/app/util/test/mock-route-messenger.ts b/app/util/test/mock-route-messenger.ts new file mode 100644 index 00000000000..8635abf8037 --- /dev/null +++ b/app/util/test/mock-route-messenger.ts @@ -0,0 +1,50 @@ +import { + ActionHandler, + Messenger, + MOCK_ANY_NAMESPACE, + MockAnyNamespace, +} from '@metamask/messenger'; +import { + UIMessengerActions, + UIMessengerEvents, +} from '../../messengers/ui-messenger'; +import { RouteMessenger } from '../../messengers/route-messenger'; + +/** + * Create a route messenger for testing with the specified action handlers. + * + * This creates a real Messenger instance with a fake namespace and registers + * the provided action handlers on it. + * + * @param actionHandlers - A map of action type strings to handler functions. + * @returns A messenger with the specified action handlers registered. + * @example + * ```typescript + * const addNetwork = jest.fn().mockResolvedValue({ chainId: '0x1' }); + * const messenger = createMockRouteMessenger({ + * 'NetworkController:addNetwork': addNetwork, + * }); + * ``` + */ +export function createMockRouteMessenger< + Actions extends UIMessengerActions = never, +>(actionHandlers?: { + [Action in Actions as Action['type']]: Action['handler']; +}): RouteMessenger { + const messenger = new Messenger< + MockAnyNamespace, + UIMessengerActions, + UIMessengerEvents + >({ + namespace: MOCK_ANY_NAMESPACE, + }); + + for (const [actionType, handler] of Object.entries(actionHandlers ?? {})) { + messenger.registerActionHandler( + actionType as Actions['type'], + handler as ActionHandler, + ); + } + + return messenger; +} diff --git a/app/util/test/mock-ui-messenger.ts b/app/util/test/mock-ui-messenger.ts new file mode 100644 index 00000000000..fea4eb76066 --- /dev/null +++ b/app/util/test/mock-ui-messenger.ts @@ -0,0 +1,71 @@ +import { + ActionConstraint, + ActionHandler, + EventConstraint, + Messenger, +} from '@metamask/messenger'; +import type { + UIMessenger, + UIMessengerActions, + UIMessengerEvents, +} from '../../messengers/ui-messenger'; + +type DelegateeMessenger = Messenger; + +interface DelegateArgs< + Actions extends UIMessengerActions, + Events extends UIMessengerEvents, +> { + actions?: Actions['type'][]; + events?: Events['type'][]; + messenger: DelegateeMessenger; +} + +/** + * Create a UI messenger for testing with the specified action handlers. + * + * This creates a real Messenger instance with a fake namespace and registers + * the provided action handlers on it. + * + * @param actionHandlers - A map of action type strings to handler functions. + * @returns A messenger with the specified action handlers registered. + * @example + * ```typescript + * const addNetwork = jest.fn().mockResolvedValue({ chainId: '0x1' }); + * const messenger = createMockUIMessenger({ + * 'NetworkController:addNetwork': addNetwork, + * }); + * ``` + */ +export function createMockUIMessenger< + Actions extends UIMessengerActions = never, + Events extends UIMessengerEvents = never, +>(actionHandlers?: { + [Action in Actions as Action['type']]: Action['handler']; +}): UIMessenger { + return { + async delegate({ actions = [], messenger }: DelegateArgs) { + for (const actionType of actions) { + const handler = actionHandlers?.[actionType]; + if (!handler) { + throw new Error( + `No handler registered for action "${String(actionType)}".`, + ); + } + + messenger._internalRegisterDelegatedActionHandler( + actionType, + handler as ActionHandler, + ); + } + + // No background connection in tests — events are not subscribed to. + }, + + async revoke({ actions = [], messenger }: DelegateArgs) { + for (const actionType of actions) { + messenger._internalUnregisterDelegatedActionHandler(actionType); + } + }, + } as unknown as UIMessenger; +} diff --git a/app/util/test/renderWithProvider.tsx b/app/util/test/renderWithProvider.tsx index cd1d8b9929b..fe1b721096b 100644 --- a/app/util/test/renderWithProvider.tsx +++ b/app/util/test/renderWithProvider.tsx @@ -17,6 +17,11 @@ import { Theme } from '../theme/models'; import configureStore from './configureStore'; import { RootState } from '../../reducers'; import { FeatureFlagOverrideProvider } from '../../contexts/FeatureFlagOverrideContext'; +import { UIMessengerProvider } from '../../contexts/ui-messenger'; +import { createMockUIMessenger } from './mock-ui-messenger'; +import { RouteMessengerContext } from '../../contexts/route-messenger'; +import { RouteMessenger } from '../../messengers/route-messenger'; +import { UIMessenger } from '../../messengers/ui-messenger'; // DeepPartial is a generic type that recursively makes all properties of a given type T optional export type DeepPartial = T extends (...args: unknown[]) => unknown @@ -30,9 +35,12 @@ export type DeepPartial = T extends (...args: unknown[]) => unknown { [K in keyof T]?: DeepPartial } : // Otherwise, return T or undefined. T | undefined; + export interface ProviderValues { state?: DeepPartial; theme?: Theme; + uiMessenger?: UIMessenger; + routeMessenger?: RouteMessenger; } export default function renderWithProvider( @@ -41,7 +49,13 @@ export default function renderWithProvider( includeNavigationContainer = true, includeFeatureFlagOverrideProvider = true, ) { - const { state = {}, theme = mockTheme } = providerValues ?? {}; + const { + state = {}, + theme = mockTheme, + uiMessenger = createMockUIMessenger(), + routeMessenger = null, + } = providerValues ?? {}; + const store = configureStore(state); // eslint-disable-next-line @typescript-eslint/no-require-imports, @typescript-eslint/no-var-requires require('../../store')._updateMockState(state); @@ -55,11 +69,22 @@ export default function renderWithProvider( ); } + + if (routeMessenger) { + wrappedChildren = ( + + {wrappedChildren} + + ); + } + return ( - - {wrappedChildren} - + + + {wrappedChildren} + + ); }; @@ -106,13 +131,34 @@ export function renderHookWithProvider( hook: (props: Props) => Result, providerValues?: ProviderValues, ) { - const { state = {} } = providerValues ?? {}; + const { + state = {}, + uiMessenger = createMockUIMessenger(), + routeMessenger = null, + } = providerValues ?? {}; + const store = configureStore(state); // eslint-disable-next-line @typescript-eslint/no-require-imports, @typescript-eslint/no-var-requires require('../../store')._updateMockState(state); - const Providers = ({ children }: { children: React.ReactElement }) => ( - {children} - ); + + const Providers = ({ children }: { children: React.ReactElement }) => { + let wrappedChildren = children; + if (routeMessenger) { + wrappedChildren = ( + + {wrappedChildren} + + ); + } + + return ( + + + {wrappedChildren} + + + ); + }; return { ...renderHook(hook, { wrapper: Providers } as RenderHookOptions), From 6c2b913e839ba16d95690c240082a00cfe040453 Mon Sep 17 00:00:00 2001 From: Jyoti Puri Date: Wed, 10 Jun 2026 19:10:45 +0530 Subject: [PATCH 07/15] chore: update @metamask/transaction-pay-controller to version 23.5.0 (#31466) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ## **Description** update @metamask/transaction-pay-controller to version 23.5.0 ## **Changelog** CHANGELOG entry: ## **Related issues** Fixes: https://consensyssoftware.atlassian.net/browse/CONF-1520 ## **Manual testing steps** NA ## **Screenshots/Recordings** NA ## **Pre-merge author checklist** - [X] I've followed [MetaMask Contributor Docs](https://github.com/MetaMask/contributor-docs) and [MetaMask Mobile Coding Standards](https://github.com/MetaMask/metamask-mobile/blob/main/.github/guidelines/CODING_GUIDELINES.md). - [X] I've completed the PR template to the best of my ability - [X] I've included tests if applicable - [X] I've documented my code using [JSDoc](https://jsdoc.app/) format if applicable - [X] I've applied the right labels on the PR (see [labeling guidelines](https://github.com/MetaMask/metamask-mobile/blob/main/.github/guidelines/LABELING_GUIDELINES.md)). Not required for external contributors. #### Performance checks (if applicable) - [X] I've tested on Android - Ideally on a mid-range device; emulator is acceptable - [X] I've tested with a power user scenario - Use these [power-user SRPs](https://consensyssoftware.atlassian.net/wiki/spaces/TL1/pages/edit-v2/401401446401?draftShareId=9d77e1e1-4bdc-4be1-9ebb-ccd916988d93) to import wallets with many accounts and tokens - [X] I've instrumented key operations with Sentry traces for production performance metrics - See [`trace()`](/app/util/trace.ts) for usage and [`addToken`](/app/components/Views/AddAsset/components/AddCustomToken/AddCustomToken.tsx#L274) for an example For performance guidelines and tooling, see the [Performance Guide](https://consensyssoftware.atlassian.net/wiki/spaces/TL1/pages/400085549067/Performance+Guide+for+Engineers). ## **Pre-merge reviewer checklist** - [ ] I've manually tested the PR (e.g. pull and build branch, run the app, test code being changed). - [ ] I confirm that this PR addresses all acceptance criteria described in the ticket it closes and includes the necessary testing evidence such as recordings and or screenshots. --- > [!NOTE] > **Medium Risk** > Touches bridge/swap submission paths via controller upgrades and type assertions on quote payloads; regression risk is moderate though changes are mostly dependency-driven compile fixes. > > **Overview** > Bumps **`@metamask/transaction-pay-controller`** from **^23.3.0** to **^23.5.0** and refreshes **`yarn.lock`**, pulling newer bridge stack packages (e.g. **`bridge-controller` ~75**, **`bridge-status-controller` 72.1.0**) and related controller bumps such as **`keyring-controller` ^27**. > > To satisfy TypeScript after those nested **`bridge-controller`** copies diverge, **`useSubmitBridgeTx`** and **`useSubmitBatchSellTx`** cast **`QuoteResponse`** / quote payloads to **`BridgeStatusController`** parameter types, with comments explaining the v74 vs v75 nominal type mismatch. > > Test/fixture updates rename the Monad network label from **"Monad Mainnet"** to **"Monad"** in **`initial-background-state.json`** and **`AddressSelector.test.tsx`**. > > Reviewed by [Cursor Bugbot](https://cursor.com/bugbot) for commit 1ba055ece92958e94e8e50ae7b1d5410d2ccaba6. Bugbot is set up for automated code reviews on this repo. Configure [here](https://www.cursor.com/dashboard/bugbot). --- .../hooks/useSubmitBatchSellTx/index.ts | 9 +- .../AddressSelector/AddressSelector.test.tsx | 2 +- app/util/bridge/hooks/useSubmitBridgeTx.ts | 7 +- app/util/test/initial-background-state.json | 2 +- package.json | 2 +- yarn.lock | 261 +++++++++--------- 6 files changed, 153 insertions(+), 130 deletions(-) diff --git a/app/components/UI/Bridge/hooks/useSubmitBatchSellTx/index.ts b/app/components/UI/Bridge/hooks/useSubmitBatchSellTx/index.ts index c93eca484de..5582db5a92b 100644 --- a/app/components/UI/Bridge/hooks/useSubmitBatchSellTx/index.ts +++ b/app/components/UI/Bridge/hooks/useSubmitBatchSellTx/index.ts @@ -4,6 +4,7 @@ import type { QuoteMetadata, QuoteResponse, } from '@metamask/bridge-controller'; +import type { BridgeStatusController } from '@metamask/bridge-status-controller'; import Engine from '../../../../../core/Engine'; import { selectBatchSellDestToken } from '../../../../../core/redux/slices/bridge'; @@ -37,8 +38,14 @@ export function useSubmitBatchSellTx() { : quoteResponse, ); + // Type assertion needed: QuoteResponse/QuoteMetadata are imported from + // @metamask/bridge-controller v74 but submitBatchSell expects types from + // the v75 copy nested in @metamask/bridge-status-controller (FeatureId + // enum is structurally identical but nominally incompatible). return await Engine.context.BridgeStatusController.submitBatchSell({ - quoteResponses: normalizedQuoteResponses, + quoteResponses: normalizedQuoteResponses as Parameters< + BridgeStatusController['submitBatchSell'] + >[0]['quoteResponses'], accountAddress: walletAddress, location, isStxEnabled: stxEnabled, diff --git a/app/components/Views/AddressSelector/AddressSelector.test.tsx b/app/components/Views/AddressSelector/AddressSelector.test.tsx index 7945af35f56..ea709a69843 100644 --- a/app/components/Views/AddressSelector/AddressSelector.test.tsx +++ b/app/components/Views/AddressSelector/AddressSelector.test.tsx @@ -142,7 +142,7 @@ describe('AccountSelector', () => { ARBITRUM_DISPLAY_NAME, BASE_DISPLAY_NAME, BNB_DISPLAY_NAME, - 'Monad Mainnet', + 'Monad', OPTIMISM_DISPLAY_NAME, POLYGON_DISPLAY_NAME, ]); diff --git a/app/util/bridge/hooks/useSubmitBridgeTx.ts b/app/util/bridge/hooks/useSubmitBridgeTx.ts index 05f0bed0486..59a67b5d280 100644 --- a/app/util/bridge/hooks/useSubmitBridgeTx.ts +++ b/app/util/bridge/hooks/useSubmitBridgeTx.ts @@ -3,6 +3,7 @@ import type { QuoteMetadata, QuoteResponse, } from '@metamask/bridge-controller'; +import type { BridgeStatusController } from '@metamask/bridge-status-controller'; import Engine from '../../../core/Engine'; import { useSelector } from 'react-redux'; import { selectShouldUseSmartTransaction } from '../../../selectors/smartTransactionsController'; @@ -139,7 +140,9 @@ export default function useSubmitBridgeTx() { // check whether quoteResponse is an intent transaction if (quoteResponse.quote.intent) { return await Engine.context.BridgeStatusController.submitIntent({ - quoteResponse, + quoteResponse: quoteResponse as Parameters< + BridgeStatusController['submitIntent'] + >[0]['quoteResponse'], accountAddress: walletAddress, location, abTests, @@ -152,7 +155,7 @@ export default function useSubmitBridgeTx() { { ...quoteResponse, approval: quoteResponse.approval ?? undefined, - }, + } as Parameters[1], stxEnabled, undefined, // quotesReceivedContext location, diff --git a/app/util/test/initial-background-state.json b/app/util/test/initial-background-state.json index b646f603d97..26b17517592 100644 --- a/app/util/test/initial-background-state.json +++ b/app/util/test/initial-background-state.json @@ -242,7 +242,7 @@ "blockExplorerUrls": [], "chainId": "0x8f", "defaultRpcEndpointIndex": 0, - "name": "Monad Mainnet", + "name": "Monad", "nativeCurrency": "MON", "rpcEndpoints": [ { diff --git a/package.json b/package.json index 6425aa885df..b2b280c333e 100644 --- a/package.json +++ b/package.json @@ -345,7 +345,7 @@ "@metamask/superstruct": "^3.2.1", "@metamask/swappable-obj-proxy": "^2.1.0", "@metamask/transaction-controller": "^67.0.0", - "@metamask/transaction-pay-controller": "^23.3.0", + "@metamask/transaction-pay-controller": "^23.5.0", "@metamask/tron-wallet-snap": "^1.25.6", "@metamask/utils": "^11.11.0", "@metamask/wallet": "^2.0.0", diff --git a/yarn.lock b/yarn.lock index 5ecffada0db..60a78a5d285 100644 --- a/yarn.lock +++ b/yarn.lock @@ -7762,16 +7762,16 @@ __metadata: languageName: node linkType: hard -"@metamask/account-tree-controller@npm:^7.2.0, @metamask/account-tree-controller@npm:^7.5.0, @metamask/account-tree-controller@npm:^7.5.1": - version: 7.5.1 - resolution: "@metamask/account-tree-controller@npm:7.5.1" +"@metamask/account-tree-controller@npm:^7.2.0, @metamask/account-tree-controller@npm:^7.5.0, @metamask/account-tree-controller@npm:^7.5.2": + version: 7.5.2 + resolution: "@metamask/account-tree-controller@npm:7.5.2" dependencies: - "@metamask/accounts-controller": "npm:^39.0.0" + "@metamask/accounts-controller": "npm:^39.0.1" "@metamask/base-controller": "npm:^9.1.0" "@metamask/keyring-api": "npm:^23.1.0" - "@metamask/keyring-controller": "npm:^26.0.0" + "@metamask/keyring-controller": "npm:^27.0.0" "@metamask/messenger": "npm:^1.2.0" - "@metamask/multichain-account-service": "npm:^10.0.2" + "@metamask/multichain-account-service": "npm:^10.0.3" "@metamask/profile-sync-controller": "npm:^28.1.1" "@metamask/snaps-controllers": "npm:^19.0.0" "@metamask/snaps-sdk": "npm:^11.0.0" @@ -7783,7 +7783,7 @@ __metadata: peerDependencies: "@metamask/providers": ^22.0.0 webextension-polyfill: ^0.10.0 || ^0.11.0 || ^0.12.0 - checksum: 10/e037c51b800f4c63fbb6ca17d2e2c5f606e5b2456b3435d8e58358e83791c598f2a80d789799371029b7db539baf29b6fe8ef6cb6db12bcf2bba2bf7f71670cf + checksum: 10/cb503b2b6eacbe1e7edfe88897d995c2d6190a042a13ec6248f312be3ebcb178d9d19ec9f74fe8bdc052e1627dfd675023c009e8e22b516f9f493c17cbad0c12 languageName: node linkType: hard @@ -7867,59 +7867,59 @@ __metadata: languageName: node linkType: hard -"@metamask/approval-controller@npm:^9.0.0, @metamask/approval-controller@npm:^9.0.1": - version: 9.0.1 - resolution: "@metamask/approval-controller@npm:9.0.1" +"@metamask/approval-controller@npm:^9.0.0, @metamask/approval-controller@npm:^9.0.1, @metamask/approval-controller@npm:^9.0.2": + version: 9.0.2 + resolution: "@metamask/approval-controller@npm:9.0.2" dependencies: - "@metamask/base-controller": "npm:^9.0.1" - "@metamask/messenger": "npm:^1.0.0" + "@metamask/base-controller": "npm:^9.1.0" + "@metamask/messenger": "npm:^1.2.0" "@metamask/rpc-errors": "npm:^7.0.2" "@metamask/utils": "npm:^11.9.0" nanoid: "npm:^3.3.8" - checksum: 10/980e7ded7022a887c11693226922f9814d160c93fe5297380addafebe9b6e9191ba3acc7bf54775c8c8eeb7e07bcfcaaf79cc90361ff18fa04c1d449eab2ed33 + checksum: 10/f27d75901a1a8367a8972b30ebb097a063282c8eb40f3188a6255ed1e043313085de6d5feb9300660366d4d93c822ebe6b37219978806db0492c4fed761d3c6a languageName: node linkType: hard -"@metamask/assets-controller@npm:^8.3.1, @metamask/assets-controller@npm:^8.3.2": - version: 8.3.2 - resolution: "@metamask/assets-controller@npm:8.3.2" +"@metamask/assets-controller@npm:^8.3.1, @metamask/assets-controller@npm:^8.3.2, @metamask/assets-controller@npm:^8.3.3": + version: 8.3.3 + resolution: "@metamask/assets-controller@npm:8.3.3" dependencies: "@ethereumjs/util": "npm:^9.1.0" "@ethersproject/abi": "npm:^5.7.0" "@ethersproject/providers": "npm:^5.7.0" - "@metamask/account-tree-controller": "npm:^7.5.1" - "@metamask/accounts-controller": "npm:^39.0.0" - "@metamask/assets-controllers": "npm:^108.5.0" + "@metamask/account-tree-controller": "npm:^7.5.2" + "@metamask/accounts-controller": "npm:^39.0.1" + "@metamask/assets-controllers": "npm:^108.6.0" "@metamask/base-controller": "npm:^9.1.0" "@metamask/client-controller": "npm:^1.0.1" - "@metamask/controller-utils": "npm:^12.1.0" - "@metamask/core-backend": "npm:^6.3.2" + "@metamask/controller-utils": "npm:^12.1.1" + "@metamask/core-backend": "npm:^6.3.3" "@metamask/keyring-api": "npm:^23.1.0" - "@metamask/keyring-controller": "npm:^26.0.0" + "@metamask/keyring-controller": "npm:^27.0.0" "@metamask/keyring-internal-api": "npm:^11.0.1" "@metamask/keyring-snap-client": "npm:^9.0.2" "@metamask/messenger": "npm:^1.2.0" "@metamask/network-controller": "npm:^32.0.0" - "@metamask/network-enablement-controller": "npm:^5.2.0" + "@metamask/network-enablement-controller": "npm:^5.3.0" "@metamask/permission-controller": "npm:^13.1.1" "@metamask/phishing-controller": "npm:^17.2.0" "@metamask/polling-controller": "npm:^16.0.6" "@metamask/preferences-controller": "npm:^23.1.0" "@metamask/snaps-controllers": "npm:^19.0.0" "@metamask/snaps-utils": "npm:^12.1.2" - "@metamask/transaction-controller": "npm:^66.0.1" + "@metamask/transaction-controller": "npm:^67.0.0" "@metamask/utils": "npm:^11.9.0" async-mutex: "npm:^0.5.0" bignumber.js: "npm:^9.1.2" lodash: "npm:^4.17.21" p-limit: "npm:^3.1.0" - checksum: 10/8a1fac3cb15503059ef297b9e4868e38d4571297017745d2341413389c49a1fd8b24c8c3a4f55e82be3485c2a1bb7e8e36bb5f93b6d44f19e29e7ade89584535 + checksum: 10/aaa1414447aa37913d1a97faf4ea8fc999222346253025caa8a5599e4ed6fd354cb38fd7bca33b23431f0b43ab884b00d4ea6e98c1d94a5cd46b8c01aa14f8c1 languageName: node linkType: hard -"@metamask/assets-controllers@npm:^108.4.0, @metamask/assets-controllers@npm:^108.5.0": - version: 108.5.0 - resolution: "@metamask/assets-controllers@npm:108.5.0" +"@metamask/assets-controllers@npm:^108.4.0, @metamask/assets-controllers@npm:^108.5.0, @metamask/assets-controllers@npm:^108.6.0": + version: 108.6.0 + resolution: "@metamask/assets-controllers@npm:108.6.0" dependencies: "@ethereumjs/util": "npm:^9.1.0" "@ethersproject/abi": "npm:^5.7.0" @@ -7928,21 +7928,21 @@ __metadata: "@ethersproject/contracts": "npm:^5.7.0" "@ethersproject/providers": "npm:^5.7.0" "@metamask/abi-utils": "npm:^2.0.3" - "@metamask/account-tree-controller": "npm:^7.5.1" - "@metamask/accounts-controller": "npm:^39.0.0" - "@metamask/approval-controller": "npm:^9.0.1" + "@metamask/account-tree-controller": "npm:^7.5.2" + "@metamask/accounts-controller": "npm:^39.0.1" + "@metamask/approval-controller": "npm:^9.0.2" "@metamask/base-controller": "npm:^9.1.0" "@metamask/contract-metadata": "npm:^2.4.0" - "@metamask/controller-utils": "npm:^12.1.0" - "@metamask/core-backend": "npm:^6.3.2" + "@metamask/controller-utils": "npm:^12.1.1" + "@metamask/core-backend": "npm:^6.3.3" "@metamask/eth-query": "npm:^4.0.0" "@metamask/keyring-api": "npm:^23.1.0" - "@metamask/keyring-controller": "npm:^26.0.0" + "@metamask/keyring-controller": "npm:^27.0.0" "@metamask/messenger": "npm:^1.2.0" "@metamask/metamask-eth-abis": "npm:^3.1.1" - "@metamask/multichain-account-service": "npm:^10.0.2" + "@metamask/multichain-account-service": "npm:^10.0.3" "@metamask/network-controller": "npm:^32.0.0" - "@metamask/network-enablement-controller": "npm:^5.2.0" + "@metamask/network-enablement-controller": "npm:^5.3.0" "@metamask/permission-controller": "npm:^13.1.1" "@metamask/phishing-controller": "npm:^17.2.0" "@metamask/polling-controller": "npm:^16.0.6" @@ -7952,8 +7952,8 @@ __metadata: "@metamask/snaps-controllers": "npm:^19.0.0" "@metamask/snaps-sdk": "npm:^11.0.0" "@metamask/snaps-utils": "npm:^12.1.2" - "@metamask/storage-service": "npm:^1.0.1" - "@metamask/transaction-controller": "npm:^66.0.1" + "@metamask/storage-service": "npm:^1.0.2" + "@metamask/transaction-controller": "npm:^67.0.0" "@metamask/utils": "npm:^11.9.0" "@tanstack/query-core": "npm:^5.62.16" "@types/bn.js": "npm:^5.1.5" @@ -7970,7 +7970,7 @@ __metadata: peerDependencies: "@metamask/providers": ^22.0.0 webextension-polyfill: ^0.10.0 || ^0.11.0 || ^0.12.0 - checksum: 10/724c5b0a15d74fb3e28842003c4806a287b3fc31acd68dd40b6fb3eda30e70150d35dc391bd3690c4a0b413a4f215c77af04446bb8f2a348952224bc85541938 + checksum: 10/562b935afa642af263115870b2bd69ae726223c8b84381a0c78c7e154bc59159ee0b68a06e1edabb1f88669e359369eebd9bf7d9e3b0ab9c50334a4a4d5ca01d languageName: node linkType: hard @@ -8008,6 +8008,19 @@ __metadata: languageName: node linkType: hard +"@metamask/authenticated-user-storage@npm:^1.0.1": + version: 1.0.1 + resolution: "@metamask/authenticated-user-storage@npm:1.0.1" + dependencies: + "@metamask/base-data-service": "npm:^0.1.2" + "@metamask/controller-utils": "npm:^12.0.0" + "@metamask/messenger": "npm:^1.2.0" + "@metamask/superstruct": "npm:^3.1.0" + "@metamask/utils": "npm:^11.9.0" + checksum: 10/e6ae2f9a094536e269ec73f87a9939701bf2ce9fd3bf32671da49341da8d13179d55a4e34a1c9d899e48e707c7a1d12e36faaf1f0b4e5fe82445ca43b5265fc5 + languageName: node + linkType: hard + "@metamask/authenticated-user-storage@npm:^2.0.0": version: 2.0.0 resolution: "@metamask/authenticated-user-storage@npm:2.0.0" @@ -8069,9 +8082,9 @@ __metadata: languageName: node linkType: hard -"@metamask/bridge-controller@npm:^73.2.1": - version: 73.2.1 - resolution: "@metamask/bridge-controller@npm:73.2.1" +"@metamask/bridge-controller@npm:^74.0.0": + version: 74.0.0 + resolution: "@metamask/bridge-controller@npm:74.0.0" dependencies: "@ethersproject/address": "npm:^5.7.0" "@ethersproject/bignumber": "npm:^5.7.0" @@ -8093,29 +8106,29 @@ __metadata: "@metamask/profile-sync-controller": "npm:^28.1.1" "@metamask/remote-feature-flag-controller": "npm:^4.2.2" "@metamask/snaps-controllers": "npm:^19.0.0" - "@metamask/transaction-controller": "npm:^66.0.1" + "@metamask/transaction-controller": "npm:^67.0.0" "@metamask/utils": "npm:^11.9.0" bignumber.js: "npm:^9.1.2" reselect: "npm:^5.1.1" uuid: "npm:^8.3.2" - checksum: 10/d1a540ace86ed9226ae5f02a65b6f00ab97d22a82fa14d766e2bebe9e4cc14ba6cbc36d965636916e136a17a9c85aa22ccb286425a760c9327de429230704a50 + checksum: 10/00f9f88567a0f43b1694bfd2becaef6036fc7fbdd0ad11590e0727d9e0163db241f354fd3dd9f22d731111be32b67a4e3c6e8f6d45b14dcaf1522110fc66f481 languageName: node linkType: hard -"@metamask/bridge-controller@npm:^74.0.0": - version: 74.0.0 - resolution: "@metamask/bridge-controller@npm:74.0.0" +"@metamask/bridge-controller@npm:^75.0.0, @metamask/bridge-controller@npm:^75.1.0": + version: 75.1.0 + resolution: "@metamask/bridge-controller@npm:75.1.0" dependencies: "@ethersproject/address": "npm:^5.7.0" "@ethersproject/bignumber": "npm:^5.7.0" "@ethersproject/constants": "npm:^5.7.0" "@ethersproject/contracts": "npm:^5.7.0" "@ethersproject/providers": "npm:^5.7.0" - "@metamask/accounts-controller": "npm:^39.0.0" - "@metamask/assets-controller": "npm:^8.3.2" - "@metamask/assets-controllers": "npm:^108.5.0" + "@metamask/accounts-controller": "npm:^39.0.1" + "@metamask/assets-controller": "npm:^8.3.3" + "@metamask/assets-controllers": "npm:^108.6.0" "@metamask/base-controller": "npm:^9.1.0" - "@metamask/controller-utils": "npm:^12.1.0" + "@metamask/controller-utils": "npm:^12.1.1" "@metamask/gas-fee-controller": "npm:^26.2.2" "@metamask/keyring-api": "npm:^23.1.0" "@metamask/messenger": "npm:^1.2.0" @@ -8126,36 +8139,36 @@ __metadata: "@metamask/profile-sync-controller": "npm:^28.1.1" "@metamask/remote-feature-flag-controller": "npm:^4.2.2" "@metamask/snaps-controllers": "npm:^19.0.0" - "@metamask/transaction-controller": "npm:^67.0.0" + "@metamask/transaction-controller": "npm:^67.1.0" "@metamask/utils": "npm:^11.9.0" bignumber.js: "npm:^9.1.2" reselect: "npm:^5.1.1" uuid: "npm:^8.3.2" - checksum: 10/00f9f88567a0f43b1694bfd2becaef6036fc7fbdd0ad11590e0727d9e0163db241f354fd3dd9f22d731111be32b67a4e3c6e8f6d45b14dcaf1522110fc66f481 + checksum: 10/bc4c2ab91337cfc2a277b9dbe3d02bd6600af2f174fd157e68c69e07839cb34d0b7fe6ccdf903535a3265380b4641c48a28b4a7e1dc489629ac8ae6e9b1b5a7f languageName: node linkType: hard -"@metamask/bridge-status-controller@npm:^72.0.2": - version: 72.0.2 - resolution: "@metamask/bridge-status-controller@npm:72.0.2" +"@metamask/bridge-status-controller@npm:^72.0.2, @metamask/bridge-status-controller@npm:^72.1.0": + version: 72.1.0 + resolution: "@metamask/bridge-status-controller@npm:72.1.0" dependencies: - "@metamask/accounts-controller": "npm:^39.0.0" + "@metamask/accounts-controller": "npm:^39.0.1" "@metamask/base-controller": "npm:^9.1.0" - "@metamask/bridge-controller": "npm:^73.2.1" - "@metamask/controller-utils": "npm:^12.1.0" + "@metamask/bridge-controller": "npm:^75.0.0" + "@metamask/controller-utils": "npm:^12.1.1" "@metamask/gas-fee-controller": "npm:^26.2.2" - "@metamask/keyring-controller": "npm:^26.0.0" + "@metamask/keyring-controller": "npm:^27.0.0" "@metamask/messenger": "npm:^1.2.0" "@metamask/network-controller": "npm:^32.0.0" "@metamask/polling-controller": "npm:^16.0.6" "@metamask/profile-sync-controller": "npm:^28.1.1" "@metamask/snaps-controllers": "npm:^19.0.0" "@metamask/superstruct": "npm:^3.1.0" - "@metamask/transaction-controller": "npm:^66.0.1" + "@metamask/transaction-controller": "npm:^67.1.0" "@metamask/utils": "npm:^11.9.0" bignumber.js: "npm:^9.1.2" uuid: "npm:^8.3.2" - checksum: 10/60f2849e351bfa39c60f18bf30056e0720ad39fce4982a70bdf85b71e85a7da71e9174ea93c3bbf69fa65618ae27ea9fc55ea0e9cfae14b79b7445509e962260 + checksum: 10/61b940736a8ce327de7e6bc87ed02a5256b0ad7fd6b3c2118d82592d3019b2c134b1779acdb4ea841df4d1b4f75a4046deb7157563223e98ac74b02d08696fa9 languageName: node linkType: hard @@ -8284,9 +8297,9 @@ __metadata: languageName: node linkType: hard -"@metamask/controller-utils@npm:^12.0.0, @metamask/controller-utils@npm:^12.1.0": - version: 12.1.0 - resolution: "@metamask/controller-utils@npm:12.1.0" +"@metamask/controller-utils@npm:^12.0.0, @metamask/controller-utils@npm:^12.1.0, @metamask/controller-utils@npm:^12.1.1": + version: 12.1.1 + resolution: "@metamask/controller-utils@npm:12.1.1" dependencies: "@metamask/eth-query": "npm:^4.0.0" "@metamask/ethjs-unit": "npm:^0.3.0" @@ -8301,24 +8314,24 @@ __metadata: lodash: "npm:^4.17.21" peerDependencies: "@babel/runtime": ^7.0.0 - checksum: 10/421b9685faefa481529e611a11cf005cd94ced8f4a15a6ea1aacd885417be0de6191ee414e0bcb0fff7ecade9e356a4d0f3d7c826e798ba47d65e412aa1df049 + checksum: 10/39e22e0247ee26a83316563c35ec206053bf95310e66441a6d416f33837f62da97abea1d52429b208e22b23fc97ad13b8a6d93d94607638b0567a45efdab1933 languageName: node linkType: hard -"@metamask/core-backend@npm:^6.3.0, @metamask/core-backend@npm:^6.3.2": - version: 6.3.2 - resolution: "@metamask/core-backend@npm:6.3.2" +"@metamask/core-backend@npm:^6.3.0, @metamask/core-backend@npm:^6.3.2, @metamask/core-backend@npm:^6.3.3": + version: 6.3.3 + resolution: "@metamask/core-backend@npm:6.3.3" dependencies: - "@metamask/accounts-controller": "npm:^39.0.0" - "@metamask/controller-utils": "npm:^12.1.0" - "@metamask/keyring-controller": "npm:^26.0.0" + "@metamask/accounts-controller": "npm:^39.0.1" + "@metamask/controller-utils": "npm:^12.1.1" + "@metamask/keyring-controller": "npm:^27.0.0" "@metamask/messenger": "npm:^1.2.0" "@metamask/profile-sync-controller": "npm:^28.1.1" "@metamask/utils": "npm:^11.9.0" "@tanstack/query-core": "npm:^5.62.16" async-mutex: "npm:^0.5.0" uuid: "npm:^8.3.2" - checksum: 10/97965ba67fc3574b857a8f18b66450e141d3ee58afe0a2788d08e55253183692421373c0c076ae75e5763f5206eb7be1af4235d4ab480881a9b80fcec6538541 + checksum: 10/be94b9131e1babea048a60f058c345d42ea8423fc3a7121013be4713f7d6298a2b9e3d2c454d0a8ddfd21216f3520bf517556b9d7b5b16600f700bf7cb5d98b6 languageName: node linkType: hard @@ -8335,15 +8348,15 @@ __metadata: languageName: node linkType: hard -"@metamask/delegation-controller@npm:^3.0.1": - version: 3.0.1 - resolution: "@metamask/delegation-controller@npm:3.0.1" +"@metamask/delegation-controller@npm:^3.0.0": + version: 3.0.0 + resolution: "@metamask/delegation-controller@npm:3.0.0" dependencies: - "@metamask/base-controller": "npm:^9.1.0" - "@metamask/keyring-controller": "npm:^26.0.0" - "@metamask/messenger": "npm:^1.2.0" + "@metamask/base-controller": "npm:^9.0.1" + "@metamask/keyring-controller": "npm:^25.2.0" + "@metamask/messenger": "npm:^1.1.1" "@metamask/utils": "npm:^11.9.0" - checksum: 10/4d0c45eeb760bb7fb452c7bc5979dc8a79a1093b9d65d02e0abd92e29dd7f6f5ea7dc8ec7a9f741f8917695982ea7f28a2c56739375f41511d116fc9148edeaf + checksum: 10/39805fa41088f3e816d4ef650a81f724e4b52528ade882fd3580436acccdc2da4072658abe00d33fb27d597e88f97d104e140718d7e67b132ff1b9bba0887fa4 languageName: node linkType: hard @@ -8358,14 +8371,14 @@ __metadata: languageName: node linkType: hard -"@metamask/delegation-core@npm:^2.2.1": - version: 2.2.1 - resolution: "@metamask/delegation-core@npm:2.2.1" +"@metamask/delegation-core@npm:^2.0.0": + version: 2.0.0 + resolution: "@metamask/delegation-core@npm:2.0.0" dependencies: "@metamask/abi-utils": "npm:^3.0.0" "@metamask/utils": "npm:^11.4.0" "@noble/hashes": "npm:^1.8.0" - checksum: 10/8cc532e4a5fdc83eddb36fada6283d51629272fcce8b7c7c2d3b4addde5aff7c741365a3bcd701364460fbbcbbe94fccc0460ee3ef9af284a82f84ca708f0e06 + checksum: 10/b473160e4cb4a6d463c6015de6e90d057034d2e8f2905068e1f44f93c8247618c5d84a155e86dfaa125dacb040951643517b9a76961bf8d215c194dc4d1cc0ad languageName: node linkType: hard @@ -8376,7 +8389,7 @@ __metadata: languageName: node linkType: hard -"@metamask/delegation-deployments@npm:^1.0.0, @metamask/delegation-deployments@npm:^1.4.0": +"@metamask/delegation-deployments@npm:^1.0.0, @metamask/delegation-deployments@npm:^1.3.0": version: 1.4.0 resolution: "@metamask/delegation-deployments@npm:1.4.0" checksum: 10/e5e7b83e27daec5b1b61482647d43d4b685954818ff02687e2dbe8169a4dfe199cc8d2ed444242b60425ac2e7d2cf2a5ca29f3e69936abe6a8391f578ec693bb @@ -9188,39 +9201,39 @@ __metadata: linkType: hard "@metamask/money-account-upgrade-controller@npm:^2.0.0": - version: 2.0.3 - resolution: "@metamask/money-account-upgrade-controller@npm:2.0.3" + version: 2.0.1 + resolution: "@metamask/money-account-upgrade-controller@npm:2.0.1" dependencies: - "@metamask/authenticated-user-storage": "npm:^2.0.0" + "@metamask/authenticated-user-storage": "npm:^1.0.1" "@metamask/base-controller": "npm:^9.1.0" "@metamask/chomp-api-service": "npm:^3.1.0" - "@metamask/delegation-controller": "npm:^3.0.1" - "@metamask/delegation-core": "npm:^2.2.1" - "@metamask/delegation-deployments": "npm:^1.4.0" - "@metamask/keyring-controller": "npm:^26.0.0" + "@metamask/delegation-controller": "npm:^3.0.0" + "@metamask/delegation-core": "npm:^2.0.0" + "@metamask/delegation-deployments": "npm:^1.3.0" + "@metamask/keyring-controller": "npm:^25.5.0" "@metamask/messenger": "npm:^1.2.0" "@metamask/network-controller": "npm:^32.0.0" "@metamask/utils": "npm:^11.9.0" - checksum: 10/6ae13e6a2232bd77976228691ea13762cb6eaa18358e7d3446e5db6942734cffdc606c09e55bb989aab5049148c49a6e3a7bad34c5a91644c60e0fe93db8989c + checksum: 10/5a903ccde41188f9e61a0200f79b8bae540c0ca31d8b7e4089708c876eaba46accdd7b2b5e110e7228d724b35e8f279b2fc5e257f3dc9d5d0f2b764416f968fe languageName: node linkType: hard -"@metamask/multichain-account-service@npm:^10.0.2": - version: 10.0.2 - resolution: "@metamask/multichain-account-service@npm:10.0.2" +"@metamask/multichain-account-service@npm:^10.0.2, @metamask/multichain-account-service@npm:^10.0.3": + version: 10.0.3 + resolution: "@metamask/multichain-account-service@npm:10.0.3" dependencies: "@ethereumjs/util": "npm:^9.1.0" - "@metamask/accounts-controller": "npm:^39.0.0" + "@metamask/accounts-controller": "npm:^39.0.1" "@metamask/base-controller": "npm:^9.1.0" "@metamask/eth-snap-keyring": "npm:^22.0.1" "@metamask/key-tree": "npm:^10.1.1" "@metamask/keyring-api": "npm:^23.1.0" - "@metamask/keyring-controller": "npm:^26.0.0" + "@metamask/keyring-controller": "npm:^27.0.0" "@metamask/keyring-internal-api": "npm:^11.0.1" "@metamask/keyring-snap-client": "npm:^9.0.2" "@metamask/keyring-utils": "npm:^3.2.1" "@metamask/messenger": "npm:^1.2.0" - "@metamask/snap-account-service": "npm:^0.3.0" + "@metamask/snap-account-service": "npm:^0.3.1" "@metamask/snaps-controllers": "npm:^19.0.0" "@metamask/snaps-sdk": "npm:^11.0.0" "@metamask/snaps-utils": "npm:^12.1.2" @@ -9232,7 +9245,7 @@ __metadata: "@metamask/account-api": ^1.0.4 "@metamask/providers": ^22.0.0 webextension-polyfill: ^0.10.0 || ^0.11.0 || ^0.12.0 - checksum: 10/14ed586988071cbfca53b6d5d263b391f1818e762eb1af175da33ddca38020eb82cdfcded3578093192c891e84ab7232112e8129cd2f98f10cce2d8c1f8d4b48 + checksum: 10/ed945f17e571b96baab959c29e5bdc68573de6e613d79834fbd9eb55025e7c85d3e92ae4cf8454ccefdb45e6331ecfe9f859feb4217cbe1b3083086166128057 languageName: node linkType: hard @@ -9344,7 +9357,7 @@ __metadata: languageName: node linkType: hard -"@metamask/network-enablement-controller@npm:^5.2.0, @metamask/network-enablement-controller@npm:^5.3.0": +"@metamask/network-enablement-controller@npm:^5.3.0": version: 5.3.0 resolution: "@metamask/network-enablement-controller@npm:5.3.0" dependencies: @@ -9947,22 +9960,22 @@ __metadata: languageName: node linkType: hard -"@metamask/snap-account-service@npm:^0.3.0": - version: 0.3.0 - resolution: "@metamask/snap-account-service@npm:0.3.0" +"@metamask/snap-account-service@npm:^0.3.0, @metamask/snap-account-service@npm:^0.3.1": + version: 0.3.1 + resolution: "@metamask/snap-account-service@npm:0.3.1" dependencies: "@metamask/account-api": "npm:^1.0.4" - "@metamask/account-tree-controller": "npm:^7.5.1" + "@metamask/account-tree-controller": "npm:^7.5.2" "@metamask/eth-snap-keyring": "npm:^22.0.1" "@metamask/keyring-api": "npm:^23.1.0" - "@metamask/keyring-controller": "npm:^26.0.0" + "@metamask/keyring-controller": "npm:^27.0.0" "@metamask/keyring-snap-sdk": "npm:^9.0.1" "@metamask/messenger": "npm:^1.2.0" "@metamask/snaps-controllers": "npm:^19.0.0" "@metamask/snaps-sdk": "npm:^11.0.0" "@metamask/utils": "npm:^11.9.0" lodash: "npm:^4.17.21" - checksum: 10/b7088073cab6d3c208c33f16b14e7a2a047152efa1c432dd03702e0f4c67c8ca1fe40111f01ccfd6f77052fe9a8d79957a51a1d8272f27dd5ffa3e1d9b6cdc1d + checksum: 10/de442502dd59910e991dd57b0c107bdc8fd5b027a2190350f2d6a1a68913f0e65d0e46a3f3cc47aee8a824c5e6dc8062a9f5ecadb4f8b62b92efe71cb4404094 languageName: node linkType: hard @@ -10203,13 +10216,13 @@ __metadata: languageName: node linkType: hard -"@metamask/storage-service@npm:^1.0.0, @metamask/storage-service@npm:^1.0.1": - version: 1.0.1 - resolution: "@metamask/storage-service@npm:1.0.1" +"@metamask/storage-service@npm:^1.0.0, @metamask/storage-service@npm:^1.0.1, @metamask/storage-service@npm:^1.0.2": + version: 1.0.2 + resolution: "@metamask/storage-service@npm:1.0.2" dependencies: - "@metamask/messenger": "npm:^1.0.0" + "@metamask/messenger": "npm:^1.2.0" "@metamask/utils": "npm:^11.9.0" - checksum: 10/3ec18b85ae80d13c4928be327abb1ee0548a6c44afdb7f709434a6621c876c3de95e145ca2603bdf178772982c76f546ec1cac58f28c0a9c74e020342d171349 + checksum: 10/dda8f1a6c63c98bbd8fa61cd5863e54f8e9801389a96efc69fd8d10011cd194370990bac5b8693c7861e23769237e617946e0304a1dc9ea4da83b8d3fb6c8942 languageName: node linkType: hard @@ -10317,33 +10330,33 @@ __metadata: languageName: node linkType: hard -"@metamask/transaction-pay-controller@npm:^23.3.0": - version: 23.3.0 - resolution: "@metamask/transaction-pay-controller@npm:23.3.0" +"@metamask/transaction-pay-controller@npm:^23.5.0": + version: 23.5.0 + resolution: "@metamask/transaction-pay-controller@npm:23.5.0" dependencies: "@ethersproject/abi": "npm:^5.7.0" "@ethersproject/contracts": "npm:^5.7.0" "@ethersproject/providers": "npm:^5.7.0" - "@metamask/assets-controller": "npm:^8.3.2" - "@metamask/assets-controllers": "npm:^108.5.0" + "@metamask/assets-controller": "npm:^8.3.3" + "@metamask/assets-controllers": "npm:^108.6.0" "@metamask/base-controller": "npm:^9.1.0" - "@metamask/bridge-controller": "npm:^73.2.1" - "@metamask/bridge-status-controller": "npm:^72.0.2" - "@metamask/controller-utils": "npm:^12.1.0" + "@metamask/bridge-controller": "npm:^75.1.0" + "@metamask/bridge-status-controller": "npm:^72.1.0" + "@metamask/controller-utils": "npm:^12.1.1" "@metamask/gas-fee-controller": "npm:^26.2.2" - "@metamask/keyring-controller": "npm:^26.0.0" + "@metamask/keyring-controller": "npm:^27.0.0" "@metamask/messenger": "npm:^1.2.0" "@metamask/metamask-eth-abis": "npm:^3.1.1" "@metamask/network-controller": "npm:^32.0.0" "@metamask/ramps-controller": "npm:^14.1.1" "@metamask/remote-feature-flag-controller": "npm:^4.2.2" - "@metamask/transaction-controller": "npm:^67.0.0" + "@metamask/transaction-controller": "npm:^67.1.0" "@metamask/utils": "npm:^11.9.0" bignumber.js: "npm:^9.1.2" bn.js: "npm:^5.2.1" immer: "npm:^9.0.6" lodash: "npm:^4.17.21" - checksum: 10/2e3a89c5788c2c335740a0c2bf312c7ad28ceb8109acf07c6fdd9ce80c99f88c137dc5f0917d0434855fe58c8ccb5cd83184c1d6b0976ce4faeeedc351a5f1e0 + checksum: 10/fee0488d37c030298b29b6b8369a14bb2ff925c3c2cd1e555e8ee6236fecf040a4aaaaef6767dcca96e8154846ea7262ebc2bfc863c58657952245571a0391d0 languageName: node linkType: hard @@ -35344,7 +35357,7 @@ __metadata: "@metamask/test-dapp-multichain": "npm:^0.17.1" "@metamask/test-dapp-solana": "npm:^0.3.0" "@metamask/transaction-controller": "npm:^67.0.0" - "@metamask/transaction-pay-controller": "npm:^23.3.0" + "@metamask/transaction-pay-controller": "npm:^23.5.0" "@metamask/tron-wallet-snap": "npm:^1.25.6" "@metamask/utils": "npm:^11.11.0" "@metamask/wallet": "npm:^2.0.0" From 5f729ed74fa69b5cf24468bbd2fb7bfa8164b4e1 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Caain=C3=A3=20Jeronimo?= Date: Wed, 10 Jun 2026 11:02:15 -0300 Subject: [PATCH 08/15] fix(predict): stop game details jump and chart overlap on tab switch cp-7.81.0 (#31436) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ## **Description** This PR makes the Predict **game details** screen (`PredictGameDetailsContent`) behave like the standard `PredictMarketDetails` screen when switching between outcome-group chips (game lines / exact score / halftime) or the Positions/Outcomes tabs. It fixes two visual issues: 1. **Screen jumping on tab/chip change.** The component drove an imperative `scrollTo` on every chip selection (plus a follow-up `InteractionManager.runAfterInteractions` pass) to force the sticky header to the top, propped up by a `windowHeight`-based `minHeight` on the tab content. This caused the screen to jump downward on every switch. The regular market details screen never does this — it relies solely on the `ScrollView`'s `stickyHeaderIndices`. Removed the manual scroll machinery (`scrollRef`, `stickyHeaderY`, `pendingChipScroll`, the layout handler, and the effect) and the `minHeight` wrapper, so switching chips/tabs now only swaps the content below the sticky header and preserves scroll position. 2. **Chart overlapping the outcomes.** `PredictGameChartContent` used `flex-1` on its loaded/error/empty containers. Inside a `ScrollView`, `flex-1` collapses to zero height, so the fixed 200px chart `View` overflowed downward onto the chip bar and the first outcome card. Switched the containers to `w-full` so they size to their content — matching the market details chart (`PredictDetailsChart`), which never used `flex-1`. Both changes are layout-only. Existing unit tests for `PredictGameChart` and `PredictGameDetailsContent` (218 tests) pass unchanged, including the `reserves chart height only while loading` test. ## **Changelog** CHANGELOG entry: Fixed the Predict sports game details screen jumping and the price chart overlapping the outcomes when switching between markets ## **Related issues** Fixes: https://consensyssoftware.atlassian.net/browse/PRED-954 ## **Manual testing steps** ```gherkin Feature: Predict game details market/tab switching Scenario: Switching outcome-group chips does not jump or overlap the chart Given I open a sports game market with extended outcomes (e.g. game lines, exact score, halftime) And the price chart has loaded above the chip bar When I tap a different chip (e.g. "Exact Score") Then the screen does not jump to pin the chip bar to the top And the chart keeps its height and does not overlap the outcomes list And the outcomes for the selected chip render below the chip bar Scenario: Switching Positions/Outcomes tabs preserves scroll position Given I open a sports game market that shows the Positions/Outcomes tab bar When I switch between the Positions and Outcomes tabs Then the scroll position is preserved (no downward jump) And the chart does not overlap any content ``` ## **Screenshots/Recordings** ### **Before** ### **After** https://github.com/user-attachments/assets/ce638894-f133-40ce-89c8-77b4d38868cc ## **Pre-merge author checklist** - [x] I've followed [MetaMask Contributor Docs](https://github.com/MetaMask/contributor-docs) and [MetaMask Mobile Coding Standards](https://github.com/MetaMask/metamask-mobile/blob/main/.github/guidelines/CODING_GUIDELINES.md). - [x] I've completed the PR template to the best of my ability - [x] I've included tests if applicable - [x] I've documented my code using [JSDoc](https://jsdoc.app/) format if applicable - [x] I've applied the right labels on the PR (see [labeling guidelines](https://github.com/MetaMask/metamask-mobile/blob/main/.github/guidelines/LABELING_GUIDELINES.md)). Not required for external contributors. #### Performance checks (if applicable) - [x] I've tested on Android - Ideally on a mid-range device; emulator is acceptable - [x] I've tested with a power user scenario - Use these [power-user SRPs](https://consensyssoftware.atlassian.net/wiki/spaces/TL1/pages/edit-v2/401401446401?draftShareId=9d77e1e1-4bdc-4be1-9ebb-ccd916988d93) to import wallets with many accounts and tokens - [x] I've instrumented key operations with Sentry traces for production performance metrics - See [`trace()`](/app/util/trace.ts) for usage and [`addToken`](/app/components/Views/AddAsset/components/AddCustomToken/AddCustomToken.tsx#L274) for an example For performance guidelines and tooling, see the [Performance Guide](https://consensyssoftware.atlassian.net/wiki/spaces/TL1/pages/400085549067/Performance+Guide+for+Engineers). ## **Pre-merge reviewer checklist** - [ ] I've manually tested the PR (e.g. pull and build branch, run the app, test code being changed). - [ ] I confirm that this PR addresses all acceptance criteria described in the ticket it closes and includes the necessary testing evidence such as recordings and or screenshots. --- > [!NOTE] > **Low Risk** > Layout-only Tailwind and scroll wiring changes in Predict game details UI with no auth, data, or business-logic impact. > > **Overview** > Fixes Predict **game details** layout when users switch outcome chips or Positions/Outcomes tabs. > > **Game details scroll behavior:** Removes imperative `scrollTo` on chip change (`scrollRef`, `stickyHeaderY`, `pendingChipScroll`, layout measurement, and `InteractionManager` follow-up) and drops the `windowHeight`-based `minHeight` wrapper around tab content. Chip selection now calls `handleChipSelect` directly, relying on `ScrollView` `stickyHeaderIndices` like standard market details so scroll position is preserved and the screen no longer jumps. > > **Chart in `ScrollView`:** In `PredictGameChartContent`, replaces `flex-1` with `w-full` on loaded, error, and empty chart containers so height follows content instead of collapsing inside the scroll view, preventing the fixed-height chart from overlapping the chip bar and outcome list. > > Reviewed by [Cursor Bugbot](https://cursor.com/bugbot) for commit b15f4a1cdf2d7144735f1ed2117c0d45d0c5e56d. Bugbot is set up for automated code reviews on this repo. Configure [here](https://www.cursor.com/dashboard/bugbot). --- .../PredictGameChartContent.tsx | 6 +- .../PredictGameDetailsContent.tsx | 86 ++++--------------- 2 files changed, 19 insertions(+), 73 deletions(-) diff --git a/app/components/UI/Predict/components/PredictGameChart/PredictGameChartContent.tsx b/app/components/UI/Predict/components/PredictGameChart/PredictGameChartContent.tsx index d9e735bb812..465eeb4777d 100644 --- a/app/components/UI/Predict/components/PredictGameChart/PredictGameChartContent.tsx +++ b/app/components/UI/Predict/components/PredictGameChart/PredictGameChartContent.tsx @@ -168,7 +168,7 @@ const PredictGameChartContent: React.FC = ({ if (error) { return ( - + = ({ if (!hasData) { return ( - + No price history available @@ -235,7 +235,7 @@ const PredictGameChartContent: React.FC = ({ const isTooltipActive = activeIndex >= 0; return ( - + = ({ outcomeGroups: market.outcomeGroups ?? [], }); - const { height: windowHeight } = useWindowDimensions(); - const scrollRef = useRef(null); - const [stickyHeaderY, setStickyHeaderY] = useState(0); - const pendingChipScroll = useRef(false); - - const handleStickyHeaderLayout = useCallback( - (e: { nativeEvent: { layout: { y: number } } }) => { - setStickyHeaderY(e.nativeEvent.layout.y); - }, - [], - ); - - const onChipSelect = useCallback( - (key: string) => { - scrollRef.current?.scrollTo({ - y: stickyHeaderY, - animated: false, - }); - handleChipSelect(key); - pendingChipScroll.current = true; - }, - [handleChipSelect, stickyHeaderY], - ); - - // Guard: only scroll after an explicit chip selection (pendingChipScroll is - // set to true in onChipSelect). This prevents scrolling on initial render - // or when stickyHeaderY updates from layout measurements. - useEffect(() => { - if (!pendingChipScroll.current) return; - pendingChipScroll.current = false; - const handle = InteractionManager.runAfterInteractions(() => { - scrollRef.current?.scrollTo({ - y: stickyHeaderY, - animated: false, - }); - }); - return () => handle.cancel(); - }, [activeChipKey, stickyHeaderY]); - const showStickyHeader = showTabBar || showChips; const hasExtendedOutcomes = tabsEnabled && groupMap.size > 0; const showFooter = @@ -199,7 +148,6 @@ const PredictGameDetailsContent: React.FC = ({ = ({ {showStickyHeader && ( - + {showTabBar && ( = ({ )} )} - - - + {showFooter && ( From 6d56f297e96586b61903769a1d2252293ea64030 Mon Sep 17 00:00:00 2001 From: ieow <4881057+ieow@users.noreply.github.com> Date: Wed, 10 Jun 2026 22:25:49 +0800 Subject: [PATCH 09/15] fix: Consolidate Agentic CLI connection loading into host adapter (#31123) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ## **Description** Agentic CLI connection loading duplicated the same notification logic already implemented in the SDK Connect V2 host application adapter (`agenticCliLoading.ts` dispatched `showSimpleNotification` / `hideNotificationById` directly). This PR removes that duplicate module and routes Agentic CLI loading through `deps.hostapp.showConnectionLoading` / `hideConnectionLoading`, keeping a single code path for connection-loading UI. `showConnectionLoading` now accepts optional `ShowConnectionLoadingOptions` with `autodismissMs`. The default remains **10s** for standard SDK Connect flows; Agentic CLI passes **15s** via `AGENTIC_CLI_CONNECTION_LOADING_AUTODISMISS_MS` to accommodate the longer MWP → OTP → dashboard connect sequence. ## **Changelog** CHANGELOG entry: null ## **Related issues** Fixes: https://github.com/MetaMask/metamask-mobile/pull/30911#discussion_r3352593173 ## **Manual testing steps** ```gherkin Feature: Agentic CLI connection loading Scenario: user connects via Agentic CLI deeplink Given the app is unlocked and Agentic CLI connect is available When user opens an Agentic CLI connect deeplink Then a connection-loading notification appears with the dApp name And the notification auto-dismisses after ~15s if the flow stalls And the notification is hidden when the connect flow completes or fails Scenario: standard SDK Connect V2 connection loading is unchanged Given a non–Agentic CLI SDK Connect V2 session is establishing When connection loading is shown via the host adapter Then the loading notification still auto-dismisses after ~10s by default ``` ## **Screenshots/Recordings** ### **Before** ### **After** N/A ## **Pre-merge author checklist** - [x] I've followed [MetaMask Contributor Docs](https://github.com/MetaMask/contributor-docs) and [MetaMask Mobile Coding Standards](https://github.com/MetaMask/metamask-mobile/blob/main/.github/guidelines/CODING_GUIDELINES.md). - [x] I've completed the PR template to the best of my ability - [x] I've included tests if applicable - [x] I've documented my code using [JSDoc](https://jsdoc.app/) format if applicable - [x] I've applied the right labels on the PR (see [labeling guidelines](https://github.com/MetaMask/metamask-mobile/blob/main/.github/guidelines/LABELING_GUIDELINES.md)). Not required for external contributors. #### Performance checks (if applicable) - [x] I've tested on Android - Ideally on a mid-range device; emulator is acceptable - [x] I've tested with a power user scenario - Use these [power-user SRPs](https://consensyssoftware.atlassian.net/wiki/spaces/TL1/pages/edit-v2/401401446401?draftShareId=9d77e1e1-4bdc-4be1-9ebb-ccd916988d93) to import wallets with many accounts and tokens - [x] I've instrumented key operations with Sentry traces for production performance metrics - See [`trace()`](/app/util/trace.ts) for usage and [`addToken`](/app/components/Views/AddAsset/components/AddCustomToken/AddCustomToken.tsx#L274) for an example For performance guidelines and tooling, see the [Performance Guide](https://consensyssoftware.atlassian.net/wiki/spaces/TL1/pages/400085549067/Performance+Guide+for+Engineers). ## **Pre-merge reviewer checklist** - [ ] I've manually tested the PR (e.g. pull and build branch, run the app, test code being changed). - [ ] I confirm that this PR addresses all acceptance criteria described in the ticket it closes and includes the necessary testing evidence such as recordings and or screenshots. --- > [!NOTE] > **Low Risk** > UI notification plumbing refactor with no auth or data-path changes; default 10s autodismiss behavior is preserved for non–Agentic CLI callers. > > **Overview** > Removes the Agentic CLI–specific loading helpers in `agenticCliLoading.ts` and routes connection loading through the shared SDK Connect V2 **host application adapter** instead. > > `showConnectionLoading` / `hideConnectionLoading` on `IHostApplicationAdapter` now accept optional `ShowConnectionLoadingOptions` with `autodismissMs` (default **10s**; Agentic CLI uses exported **15s** for longer MWP → OTP → dashboard flows). `AgenticCliMwpConnectionService` calls `deps.hostapp.showConnectionLoading` / `hideConnectionLoading` with that timeout rather than dispatching notifications directly. Tests were updated accordingly and adapter coverage was added for custom autodismiss. > > Reviewed by [Cursor Bugbot](https://cursor.com/bugbot) for commit aa778c812f2e73ad17232a9944f57cfaf9a12656. Bugbot is set up for automated code reviews on this repo. Configure [here](https://www.cursor.com/dashboard/bugbot). --- .../AgenticCliMwpConnectionService.test.ts | 27 ++++++++-------- .../AgenticCliMwpConnectionService.ts | 11 +++---- app/core/AgenticCli/agenticCliLoading.ts | 31 ------------------- .../adapters/host-application-adapter.test.ts | 14 +++++++++ .../adapters/host-application-adapter.ts | 20 ++++++++++-- .../types/host-application-adapter.ts | 9 +++++- 6 files changed, 57 insertions(+), 55 deletions(-) delete mode 100644 app/core/AgenticCli/agenticCliLoading.ts diff --git a/app/core/AgenticCli/AgenticCliMwpConnectionService.test.ts b/app/core/AgenticCli/AgenticCliMwpConnectionService.test.ts index 58f9986da76..79432cdf1c5 100644 --- a/app/core/AgenticCli/AgenticCliMwpConnectionService.test.ts +++ b/app/core/AgenticCli/AgenticCliMwpConnectionService.test.ts @@ -1,4 +1,7 @@ -import { HostApplicationAdapter } from '../SDKConnectV2/adapters/host-application-adapter'; +import { + AGENTIC_CLI_CONNECTION_LOADING_AUTODISMISS_MS, + HostApplicationAdapter, +} from '../SDKConnectV2/adapters/host-application-adapter'; import { KeyManager } from '../SDKConnectV2/services/key-manager'; import { Connection } from '../SDKConnectV2/services/connection'; import { ConnectionInfo } from '../SDKConnectV2/types/connection-info'; @@ -11,10 +14,6 @@ import { handleAgenticCliConnectDeeplink, isAgenticCliDeeplink, } from './AgenticCliMwpConnectionService'; -import { - hideAgenticCliConnectionLoading, - showAgenticCliConnectionLoading, -} from './agenticCliLoading'; import { hideAgenticCliOtpCode, showAgenticCliOtpCode, @@ -24,10 +23,6 @@ import Engine from '../Engine'; jest.mock('../SDKConnectV2/adapters/host-application-adapter'); jest.mock('../SDKConnectV2/services/key-manager'); jest.mock('../SDKConnectV2/services/connection'); -jest.mock('./agenticCliLoading', () => ({ - showAgenticCliConnectionLoading: jest.fn(), - hideAgenticCliConnectionLoading: jest.fn(), -})); jest.mock('./agenticCliOtpUi', () => ({ showAgenticCliOtpCode: jest.fn(), hideAgenticCliOtpCode: jest.fn(), @@ -201,7 +196,7 @@ describe('AgenticCliMwpConnectionService', () => { await Promise.resolve(); expect(Connection.create).not.toHaveBeenCalled(); - expect(showAgenticCliConnectionLoading).not.toHaveBeenCalled(); + expect(mockHostApp.showConnectionLoading).not.toHaveBeenCalled(); resolveUnlock(); await promise; @@ -217,7 +212,7 @@ describe('AgenticCliMwpConnectionService', () => { conn: mockConnection, }), ); - expect(hideAgenticCliConnectionLoading).toHaveBeenCalledWith( + expect(mockHostApp.hideConnectionLoading).toHaveBeenCalledWith( expect.objectContaining({ id: mockConnectionInfo.id, }), @@ -246,7 +241,7 @@ describe('AgenticCliMwpConnectionService', () => { expect(hideAgenticCliOtpCode).toHaveBeenCalledWith( expect.objectContaining({ id: mockConnectionInfo.id }), ); - expect(hideAgenticCliConnectionLoading).toHaveBeenCalledWith( + expect(mockHostApp.hideConnectionLoading).toHaveBeenCalledWith( expect.objectContaining({ id: mockConnectionInfo.id }), ); expect(mockHostApp.showConnectionError).toHaveBeenCalled(); @@ -295,8 +290,12 @@ describe('AgenticCliMwpConnectionService', () => { cleanupConnection: jest.fn().mockResolvedValue(undefined), }); - expect(showAgenticCliConnectionLoading).toHaveBeenCalledTimes(1); - expect(hideAgenticCliConnectionLoading).toHaveBeenCalledTimes(1); + expect(mockHostApp.showConnectionLoading).toHaveBeenCalledTimes(1); + expect(mockHostApp.showConnectionLoading).toHaveBeenCalledWith( + expect.objectContaining({ id: mockConnectionInfo.id }), + { autodismissMs: AGENTIC_CLI_CONNECTION_LOADING_AUTODISMISS_MS }, + ); + expect(mockHostApp.hideConnectionLoading).toHaveBeenCalledTimes(1); expect(mockHandleAgenticCliQrLogin).toHaveBeenCalledTimes(1); }); }); diff --git a/app/core/AgenticCli/AgenticCliMwpConnectionService.ts b/app/core/AgenticCli/AgenticCliMwpConnectionService.ts index 53de2961a8e..1d88a7f3eee 100644 --- a/app/core/AgenticCli/AgenticCliMwpConnectionService.ts +++ b/app/core/AgenticCli/AgenticCliMwpConnectionService.ts @@ -12,11 +12,8 @@ import { Connection } from '../SDKConnectV2/services/connection'; import logger, { redactUrl } from '../SDKConnectV2/services/logger'; import { ConnectionInfo } from '../SDKConnectV2/types/connection-info'; import { IHostApplicationAdapter } from '../SDKConnectV2/types/host-application-adapter'; +import { AGENTIC_CLI_CONNECTION_LOADING_AUTODISMISS_MS } from '../SDKConnectV2/adapters/host-application-adapter'; import { MetaMetricsEvents } from '../Analytics/MetaMetrics.events'; -import { - hideAgenticCliConnectionLoading, - showAgenticCliConnectionLoading, -} from './agenticCliLoading'; import { hideAgenticCliOtpCode, showAgenticCliOtpCode, @@ -139,7 +136,9 @@ export async function handleAgenticCliConnectDeeplink( } // --- Create MWP connection and connect (untrusted) --- - showAgenticCliConnectionLoading(connInfo); + deps.hostapp.showConnectionLoading(connInfo, { + autodismissMs: AGENTIC_CLI_CONNECTION_LOADING_AUTODISMISS_MS, + }); agenticCliStage = 'create-mwp-connection'; conn = await Connection.create( connInfo, @@ -221,7 +220,7 @@ export async function handleAgenticCliConnectDeeplink( } } finally { if (connInfo) { - hideAgenticCliConnectionLoading(connInfo); + deps.hostapp.hideConnectionLoading(connInfo); hideAgenticCliOtpCode(connInfo); } } diff --git a/app/core/AgenticCli/agenticCliLoading.ts b/app/core/AgenticCli/agenticCliLoading.ts deleted file mode 100644 index 19370c34d58..00000000000 --- a/app/core/AgenticCli/agenticCliLoading.ts +++ /dev/null @@ -1,31 +0,0 @@ -import { store } from '../../store'; -import { - hideNotificationById, - showSimpleNotification, -} from '../../actions/notification'; -import { strings } from '../../../locales/i18n'; -import { ConnectionInfo } from '../SDKConnectV2/types/connection-info'; - -const AGENTIC_CLI_LOADING_AUTODISMISS_MS = 15_000; - -export const showAgenticCliConnectionLoading = ( - conninfo: ConnectionInfo, -): void => { - store.dispatch( - showSimpleNotification({ - id: conninfo.id, - autodismiss: AGENTIC_CLI_LOADING_AUTODISMISS_MS, - title: strings('sdk_connect_v2.show_loading.title'), - description: strings('sdk_connect_v2.show_loading.description', { - dappName: conninfo.metadata.dapp.name, - }), - status: 'pending', - }), - ); -}; - -export const hideAgenticCliConnectionLoading = ( - conninfo: ConnectionInfo, -): void => { - store.dispatch(hideNotificationById(conninfo.id)); -}; diff --git a/app/core/SDKConnectV2/adapters/host-application-adapter.test.ts b/app/core/SDKConnectV2/adapters/host-application-adapter.test.ts index 22e38e07abd..260b2604d98 100644 --- a/app/core/SDKConnectV2/adapters/host-application-adapter.test.ts +++ b/app/core/SDKConnectV2/adapters/host-application-adapter.test.ts @@ -87,6 +87,20 @@ describe('HostApplicationAdapter', () => { }); expect(store.dispatch).toHaveBeenCalledTimes(1); }); + + it('uses a custom autodismiss timeout when provided', () => { + adapter.showConnectionLoading( + createMockConnectionInfo('session-123', 'Test DApp'), + { autodismissMs: 15000 }, + ); + + expect(showSimpleNotification).toHaveBeenCalledWith( + expect.objectContaining({ + id: 'session-123', + autodismiss: 15000, + }), + ); + }); }); describe('hideConnectionLoading', () => { diff --git a/app/core/SDKConnectV2/adapters/host-application-adapter.ts b/app/core/SDKConnectV2/adapters/host-application-adapter.ts index f87b56a50e3..008612a0bf6 100644 --- a/app/core/SDKConnectV2/adapters/host-application-adapter.ts +++ b/app/core/SDKConnectV2/adapters/host-application-adapter.ts @@ -1,5 +1,8 @@ import { Connection } from '../services/connection'; -import { IHostApplicationAdapter } from '../types/host-application-adapter'; +import { + IHostApplicationAdapter, + ShowConnectionLoadingOptions, +} from '../types/host-application-adapter'; import { SDKSessions } from '../../../core/SDKConnect/SDKConnect'; import { store } from '../../../store'; import { setSdkV2Connections } from '../../../actions/sdk'; @@ -14,12 +17,23 @@ import Engine from '../../Engine'; import { Caip25EndowmentPermissionName } from '@metamask/chain-agnostic-permission'; import logger from '../services/logger'; +const DEFAULT_CONNECTION_LOADING_AUTODISMISS_MS = 10_000; + +/** Longer timeout for multi-step agentic CLI connect (MWP → OTP → dashboard). */ +export const AGENTIC_CLI_CONNECTION_LOADING_AUTODISMISS_MS = 15_000; + export class HostApplicationAdapter implements IHostApplicationAdapter { - showConnectionLoading(conninfo: ConnectionInfo): void { + showConnectionLoading( + conninfo: ConnectionInfo, + options?: ShowConnectionLoadingOptions, + ): void { + const autodismiss = + options?.autodismissMs ?? DEFAULT_CONNECTION_LOADING_AUTODISMISS_MS; + store.dispatch( showSimpleNotification({ id: conninfo.id, - autodismiss: 10000, + autodismiss, title: strings('sdk_connect_v2.show_loading.title'), description: strings('sdk_connect_v2.show_loading.description', { dappName: conninfo.metadata.dapp.name, diff --git a/app/core/SDKConnectV2/types/host-application-adapter.ts b/app/core/SDKConnectV2/types/host-application-adapter.ts index 444d61daa83..ae7c22c49dd 100644 --- a/app/core/SDKConnectV2/types/host-application-adapter.ts +++ b/app/core/SDKConnectV2/types/host-application-adapter.ts @@ -1,6 +1,10 @@ import { Connection } from '../services/connection'; import { ConnectionInfo } from './connection-info'; +export interface ShowConnectionLoadingOptions { + autodismissMs?: number; +} + /** * Defines the contract for the host MetaMask Mobile application. * This adapter is the sole boundary between the isolated SDKConnectV2 logic @@ -13,7 +17,10 @@ export interface IHostApplicationAdapter { * Displays a global, non-interactive loading modal. Used to indicate * background activity, such as establishing a connection. */ - showConnectionLoading(conninfo: ConnectionInfo): void; + showConnectionLoading( + conninfo: ConnectionInfo, + options?: ShowConnectionLoadingOptions, + ): void; /** * Hides the global loading modal. From a02cd99122f2f59023e5c82c6bebfaad756288d8 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Caain=C3=A3=20Jeronimo?= Date: Wed, 10 Jun 2026 11:34:24 -0300 Subject: [PATCH 10/15] feat(predict): add search engagement tracking to the Predict feed (PRED-927) (#31219) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ## **Description** The Predict search feature (search icon → full-screen overlay in the Predict feed) currently has **zero** analytics instrumentation. No event fires when a user opens search, types a query, or taps a result, making it impossible to measure search engagement or assess the feature's value. This PR adds a single `Predict Search Interacted` event with an `interaction_type` discriminator covering the three key moments, following the established `PredictAnalytics` controller pattern (config in `predictAnalyticsEvents.ts` → `PredictAnalytics.trackSearchInteracted` → `PredictController` pass-through, called via `Engine.context.PredictController.trackSearchInteracted(...)`). | `interaction_type` | Trigger | Extra properties | | --- | --- | --- | | `opened` | User taps the search icon | — | | `queried` | A debounced query resolves | `search_query`, `results_count` | | `result_clicked` | User taps a market result card | `search_query`, `market_id`, `market_title` | Base properties on all three: `interaction_type`, `predict_feed_tab` (omitted on the tab-less redesigned home), `entry_point`. The event is instrumented on **both** search surfaces (legacy `PredictFeed` and the redesigned `PredictHome`), which share `PredictSearchOverlay`. `opened` fires at the search-icon call sites; `queried` and `result_clicked` fire inside the overlay (the latter via `PredictMarket`'s existing `onCardPress`, so navigation is unaffected). The matching Segment schema event is added in a separate `segment-schema` PR (see Related issues) and should land first. ## **Changelog** CHANGELOG entry: null ## **Related issues** Fixes: https://consensyssoftware.atlassian.net/browse/PRED-927 Segment schema PR: https://github.com/Consensys/segment-schema/pull/601 ## **Manual testing steps** ```gherkin Feature: Predict search engagement analytics Scenario: user opens search Given the user is on the Predict feed (or redesigned home) When the user taps the search icon Then a "Predict Search Interacted" event fires with interaction_type=opened And predict_feed_tab (feed only) and entry_point are included Scenario: user runs a query Given the search overlay is open When the user types a query and the debounced search resolves Then a "Predict Search Interacted" event fires once with interaction_type=queried And search_query and results_count match the resolved results Scenario: user taps a result Given search results are shown When the user taps a market result card Then a "Predict Search Interacted" event fires with interaction_type=result_clicked And search_query, market_id, and market_title match the tapped market And the user still navigates to the market details ``` Automated coverage: - `yarn jest app/components/UI/Predict/controllers/PredictAnalytics.test.ts` (event name + per-`interaction_type` property mapping, incl. `results_count=0` and omitted optional props) - `yarn jest -c jest.config.view.js PredictFeed.view.test PredictHome.view.test` (opened/queried/result_clicked fire with correct properties) ## **Screenshots/Recordings** ### **Before** N/A — no UI changes; analytics-only instrumentation. ### **After** N/A — no UI changes; analytics-only instrumentation. ## **Pre-merge author checklist** - [x] I've followed [MetaMask Contributor Docs](https://github.com/MetaMask/contributor-docs) and [MetaMask Mobile Coding Standards](https://github.com/MetaMask/metamask-mobile/blob/main/.github/guidelines/CODING_GUIDELINES.md). - [x] I've completed the PR template to the best of my ability - [x] I've included tests if applicable - [x] I've documented my code using [JSDoc](https://jsdoc.app/) format if applicable - [x] I've applied the right labels on the PR (see [labeling guidelines](https://github.com/MetaMask/metamask-mobile/blob/main/.github/guidelines/LABELING_GUIDELINES.md)). Not required for external contributors. #### Performance checks (if applicable) - [x] I've tested on Android - [x] I've tested with a power user scenario - [x] I've instrumented key operations with Sentry traces for production performance metrics ## **Pre-merge reviewer checklist** - [x] I've manually tested the PR (e.g. pull and build branch, run the app, test code being changed). - [x] I confirm that this PR addresses all acceptance criteria described in the ticket it closes and includes the necessary testing evidence such as recordings and or screenshots. --- > [!NOTE] > **Low Risk** > Analytics-only instrumentation with no UI or business-logic changes; search queries are sent to existing metrics pipelines as designed. > > **Overview** > Adds **Predict Search Interacted** analytics so search engagement can be measured on the legacy feed and redesigned home. > > A new MetaMetrics event is wired through `PredictAnalytics` / `PredictController` with `interaction_type` values **`opened`**, **`queried`**, and **`result_clicked`**, plus optional `predict_feed_tab`, `entry_point`, `search_query`, `results_count`, and market fields. **`opened`** fires when the user taps search on `PredictFeed` or `PredictHome`; **`queried`** and **`result_clicked`** fire from shared `PredictSearchOverlay` after debounced search completes (once per query, with re-track after clear) and on result card press via `onCardPress`. > > `results_count` uses the API **`totalResults`**, not the visible page length. On home, `entry_point` is taken from route params only—no fallback to `predict_feed`. Unit and component-view tests cover property mapping and end-to-end firing. > > Reviewed by [Cursor Bugbot](https://cursor.com/bugbot) for commit 353b4bb268d98a18d2c4b1ac92fdd476fc4ebafa. Bugbot is set up for automated code reviews on this repo. Configure [here](https://www.cursor.com/dashboard/bugbot). --------- Co-authored-by: Claude Opus 4.8 --- .../PredictSearchOverlay.tsx | 76 ++++++++- .../UI/Predict/constants/eventNames.ts | 10 ++ .../controllers/PredictAnalytics.test.ts | 96 +++++++++++ .../Predict/controllers/PredictAnalytics.ts | 14 ++ .../PredictController-method-action-types.ts | 11 ++ .../Predict/controllers/PredictController.ts | 13 ++ .../utils/predictAnalyticsEvents.ts | 34 +++- .../views/PredictFeed/PredictFeed.test.tsx | 15 ++ .../Predict/views/PredictFeed/PredictFeed.tsx | 14 +- .../PredictFeed/PredictFeed.view.test.tsx | 159 +++++++++++++++++- .../Predict/views/PredictHome/PredictHome.tsx | 18 +- .../PredictHome/PredictHome.view.test.tsx | 31 ++++ app/core/Analytics/MetaMetrics.events.ts | 2 + tests/component-view/mocks.ts | 1 + 14 files changed, 484 insertions(+), 10 deletions(-) diff --git a/app/components/UI/Predict/components/PredictSearchOverlay/PredictSearchOverlay.tsx b/app/components/UI/Predict/components/PredictSearchOverlay/PredictSearchOverlay.tsx index 7fcc2964389..2266b8365a2 100644 --- a/app/components/UI/Predict/components/PredictSearchOverlay/PredictSearchOverlay.tsx +++ b/app/components/UI/Predict/components/PredictSearchOverlay/PredictSearchOverlay.tsx @@ -1,4 +1,4 @@ -import React, { useCallback } from 'react'; +import React, { useCallback, useEffect, useRef } from 'react'; import { useSelector } from 'react-redux'; import { useSafeAreaInsets } from 'react-native-safe-area-context'; import { useTailwind } from '@metamask/design-system-twrnc-preset'; @@ -19,6 +19,7 @@ import PredictMarket from '../PredictMarket'; import PredictMarketSkeleton from '../PredictMarketSkeleton'; import PredictOffline from '../PredictOffline'; import { strings } from '../../../../../../locales/i18n'; +import Engine from '../../../../../core/Engine'; import { useTheme } from '../../../../../util/theme'; import HeaderSearch, { HeaderSearchVariant, @@ -36,6 +37,10 @@ export interface PredictSearchOverlayProps { onSearchChange: (query: string) => void; onClose: () => void; transactionActiveAbTests?: TransactionActiveAbTestEntry[]; + /** Active feed tab when search was opened (omitted on the tab-less home). */ + predictFeedTab?: string; + /** How the user entered the surface where search was opened. */ + entryPoint?: string; } const SEARCH_DEBOUNCE_MS = 200; @@ -53,6 +58,8 @@ const PredictSearchOverlay: React.FC = ({ onSearchChange, onClose, transactionActiveAbTests, + predictFeedTab, + entryPoint, }) => { const tw = useTailwind(); const { colors } = useTheme(); @@ -66,28 +73,85 @@ const PredictSearchOverlay: React.FC = ({ const upDownEnabled = useSelector(selectPredictUpDownEnabledFlag); const refine = upDownEnabled ? deduplicateSeriesMarkets : undefined; - const { marketData, isFetching, error, refetch } = usePredictSearchMarketData( - { + const { marketData, totalResults, isFetching, error, refetch } = + usePredictSearchMarketData({ q: debouncedSearchQuery, pageSize: 20, refine, enabled: isVisible, - }, - ); + }); const isSearchLoading = isDebouncing || isFetching; const hasSearchQuery = debouncedSearchQuery.trim().length > 0; + // Fire `queried` once per resolved (non-empty) query — i.e. after debounce + // settles and the fetch completes without error. The ref guards against + // re-firing for the same query when `marketData` re-renders, but clearing the + // live input resets the guard immediately so the same term can be tracked if + // the user searches again without closing the overlay. + const lastTrackedQueryRef = useRef(null); + useEffect(() => { + if (!searchQuery.trim()) { + lastTrackedQueryRef.current = null; + } + }, [searchQuery]); + + useEffect(() => { + if (!isVisible) { + lastTrackedQueryRef.current = null; + return; + } + const q = debouncedSearchQuery.trim(); + if (!q || isSearchLoading || error || lastTrackedQueryRef.current === q) { + return; + } + lastTrackedQueryRef.current = q; + Engine.context.PredictController.trackSearchInteracted({ + interactionType: PredictEventValues.SEARCH_INTERACTION.QUERIED, + predictFeedTab, + entryPoint, + searchQuery: q, + // Report the server's total match count, not the visible page length. + // `marketData` is capped at `pageSize` and post-dedup, so it undercounts + // any query matching more results than the first page holds. + resultsCount: totalResults, + }); + }, [ + isVisible, + debouncedSearchQuery, + isSearchLoading, + error, + totalResults, + predictFeedTab, + entryPoint, + ]); + + const handleResultPress = useCallback( + (market: PredictMarketType) => { + Engine.context.PredictController.trackSearchInteracted({ + interactionType: PredictEventValues.SEARCH_INTERACTION.RESULT_CLICKED, + predictFeedTab, + entryPoint, + searchQuery: debouncedSearchQuery.trim(), + marketId: market.id, + marketTitle: market.title, + }); + }, + [predictFeedTab, entryPoint, debouncedSearchQuery], + ); + const renderItem = useCallback( (info: { item: PredictMarketType; index: number }) => ( handleResultPress(info.item)} /> ), - [transactionActiveAbTests], + [predictFeedTab, transactionActiveAbTests, handleResultPress], ); const keyExtractor = useCallback((item: PredictMarketType) => item.id, []); diff --git a/app/components/UI/Predict/constants/eventNames.ts b/app/components/UI/Predict/constants/eventNames.ts index 384fc208956..ee858f491b2 100644 --- a/app/components/UI/Predict/constants/eventNames.ts +++ b/app/components/UI/Predict/constants/eventNames.ts @@ -90,6 +90,11 @@ export const PredictEventProperties = { // Banner properties BANNER_TYPE: 'banner_type', + + // Search engagement + INTERACTION_TYPE: 'interaction_type', + SEARCH_QUERY: 'search_query', + RESULTS_COUNT: 'results_count', } as const; /** @@ -163,6 +168,11 @@ export const PredictEventValues = { BANNER_TYPE: { WORLD_CUP: 'world_cup', }, + SEARCH_INTERACTION: { + OPENED: 'opened', + QUERIED: 'queried', + RESULT_CLICKED: 'result_clicked', + }, } as const; /** diff --git a/app/components/UI/Predict/controllers/PredictAnalytics.test.ts b/app/components/UI/Predict/controllers/PredictAnalytics.test.ts index 8ad01115cc6..b3fd0b4b618 100644 --- a/app/components/UI/Predict/controllers/PredictAnalytics.test.ts +++ b/app/components/UI/Predict/controllers/PredictAnalytics.test.ts @@ -787,5 +787,101 @@ describe('PredictAnalytics', () => { market_slug: 'slug-10', }); }); + + it('tracks search opened with only the base properties', () => { + predictAnalytics.trackSearchInteracted({ + interactionType: PredictEventValues.SEARCH_INTERACTION.OPENED, + predictFeedTab: 'trending', + entryPoint: 'predict_feed', + }); + + const event = getTrackedEvent(); + + expect(event.name).toBe( + MetaMetricsEvents.PREDICT_SEARCH_INTERACTED.category, + ); + expect(event.properties).toEqual({ + interaction_type: 'opened', + predict_feed_tab: 'trending', + entry_point: 'predict_feed', + }); + }); + + it('tracks search queried with search query and results count', () => { + predictAnalytics.trackSearchInteracted({ + interactionType: PredictEventValues.SEARCH_INTERACTION.QUERIED, + predictFeedTab: 'crypto', + entryPoint: 'predict_feed', + searchQuery: 'eth', + resultsCount: 5, + }); + + const event = getTrackedEvent(); + + expect(event.name).toBe( + MetaMetricsEvents.PREDICT_SEARCH_INTERACTED.category, + ); + expect(event.properties).toMatchObject({ + interaction_type: 'queried', + predict_feed_tab: 'crypto', + entry_point: 'predict_feed', + search_query: 'eth', + results_count: 5, + }); + }); + + it('emits results_count of zero (does not drop the property)', () => { + predictAnalytics.trackSearchInteracted({ + interactionType: PredictEventValues.SEARCH_INTERACTION.QUERIED, + searchQuery: 'nomatch', + resultsCount: 0, + }); + + const event = getTrackedEvent(); + + expect(event.properties).toMatchObject({ + search_query: 'nomatch', + results_count: 0, + }); + }); + + it('tracks search result_clicked with market id and title', () => { + predictAnalytics.trackSearchInteracted({ + interactionType: PredictEventValues.SEARCH_INTERACTION.RESULT_CLICKED, + predictFeedTab: 'sports', + entryPoint: 'predict_feed', + searchQuery: 'cup', + marketId: 'm42', + marketTitle: 'World Cup Winner', + }); + + const event = getTrackedEvent(); + + expect(event.name).toBe( + MetaMetricsEvents.PREDICT_SEARCH_INTERACTED.category, + ); + expect(event.properties).toMatchObject({ + interaction_type: 'result_clicked', + predict_feed_tab: 'sports', + entry_point: 'predict_feed', + search_query: 'cup', + market_id: 'm42', + market_title: 'World Cup Winner', + }); + }); + + it('omits optional properties (incl. predict_feed_tab) when not provided', () => { + predictAnalytics.trackSearchInteracted({ + interactionType: PredictEventValues.SEARCH_INTERACTION.OPENED, + entryPoint: 'home_section', + }); + + const event = getTrackedEvent(); + + expect(event.properties).toEqual({ + interaction_type: 'opened', + entry_point: 'home_section', + }); + }); }); }); diff --git a/app/components/UI/Predict/controllers/PredictAnalytics.ts b/app/components/UI/Predict/controllers/PredictAnalytics.ts index b6bff98aed1..a6257b8b72a 100644 --- a/app/components/UI/Predict/controllers/PredictAnalytics.ts +++ b/app/components/UI/Predict/controllers/PredictAnalytics.ts @@ -145,6 +145,16 @@ export interface ShareActionArgs { marketSlug?: string; } +export interface SearchInteractedArgs { + interactionType: string; + predictFeedTab?: string; + entryPoint?: string; + searchQuery?: string; + resultsCount?: number; + marketId?: string; + marketTitle?: string; +} + const PREDICT_PORTFOLIO_MODULE_COMPONENT = PredictEventValues.PREDICT_COMPONENT.PREDICT_PORTFOLIO_MODULE; @@ -453,6 +463,10 @@ export class PredictAnalytics { this.trackConfiguredEvent('shareAction', params); } + public trackSearchInteracted(params: SearchInteractedArgs): void { + this.trackConfiguredEvent('searchInteracted', params); + } + private trackConfiguredEvent( configKey: keyof typeof PREDICT_ANALYTICS_EVENTS, args: object, diff --git a/app/components/UI/Predict/controllers/PredictController-method-action-types.ts b/app/components/UI/Predict/controllers/PredictController-method-action-types.ts index af4be03bef9..5c8fedfbd9c 100644 --- a/app/components/UI/Predict/controllers/PredictController-method-action-types.ts +++ b/app/components/UI/Predict/controllers/PredictController-method-action-types.ts @@ -139,6 +139,16 @@ export type PredictControllerTrackShareActionAction = { handler: PredictController['trackShareAction']; }; +/** + * Track Predict Search Interacted analytics event + * + * @public + */ +export type PredictControllerTrackSearchInteractedAction = { + type: `PredictController:trackSearchInteracted`; + handler: PredictController['trackSearchInteracted']; +}; + /** * Track Predict Betslip Dismissed analytics event * @@ -356,6 +366,7 @@ export type PredictControllerMethodActions = | PredictControllerTrackFeedViewedAction | PredictControllerTrackBannerActionAction | PredictControllerTrackShareActionAction + | PredictControllerTrackSearchInteractedAction | PredictControllerTrackBetslipDismissedAction | PredictControllerPreviewOrderAction | PredictControllerPlaceOrderAction diff --git a/app/components/UI/Predict/controllers/PredictController.ts b/app/components/UI/Predict/controllers/PredictController.ts index b179ff26ca3..1834ea83ce8 100644 --- a/app/components/UI/Predict/controllers/PredictController.ts +++ b/app/components/UI/Predict/controllers/PredictController.ts @@ -413,6 +413,7 @@ const MESSENGER_EXPOSED_METHODS = [ 'trackPositionsScreenViewed', 'trackPositionsTabViewed', 'trackPredictOrderEvent', + 'trackSearchInteracted', 'trackShareAction', ] as const; @@ -1273,6 +1274,17 @@ export class PredictController extends BaseController< this.analytics.trackShareAction(args); } + /** + * Track Predict Search Interacted analytics event + * + * @public + */ + public trackSearchInteracted( + args: Parameters[0], + ): void { + this.analytics.trackSearchInteracted(args); + } + /** * Track Predict Betslip Dismissed analytics event * @@ -3376,5 +3388,6 @@ export type { PredictControllerTrackMarketDetailsOpenedAction, PredictControllerTrackPositionViewedAction, PredictControllerTrackPredictOrderEventAction, + PredictControllerTrackSearchInteractedAction, PredictControllerTrackShareActionAction, } from './PredictController-method-action-types'; diff --git a/app/components/UI/Predict/controllers/utils/predictAnalyticsEvents.ts b/app/components/UI/Predict/controllers/utils/predictAnalyticsEvents.ts index 11784f9c0cd..ebd656c0ea3 100644 --- a/app/components/UI/Predict/controllers/utils/predictAnalyticsEvents.ts +++ b/app/components/UI/Predict/controllers/utils/predictAnalyticsEvents.ts @@ -17,7 +17,8 @@ export type PredictAnalyticsEventKey = | 'shareAction' | 'geoBlockTriggered' | 'marketDetailsOpened' - | 'bannerAction'; + | 'bannerAction' + | 'searchInteracted'; const mapPortfolioProperties = ({ actionType, @@ -198,4 +199,35 @@ export const PREDICT_ANALYTICS_EVENTS: Record< : {}), }), }, + searchInteracted: { + event: MetaMetricsEvents.PREDICT_SEARCH_INTERACTED, + logLabel: '📊 [Analytics] PREDICT_SEARCH_INTERACTED', + mapProperties: ({ + interactionType, + predictFeedTab, + entryPoint, + searchQuery, + resultsCount, + marketId, + marketTitle, + }) => ({ + [PredictEventProperties.INTERACTION_TYPE]: interactionType, + ...(predictFeedTab + ? { [PredictEventProperties.PREDICT_FEED_TAB]: predictFeedTab } + : {}), + ...(entryPoint + ? { [PredictEventProperties.ENTRY_POINT]: entryPoint } + : {}), + ...(searchQuery !== undefined + ? { [PredictEventProperties.SEARCH_QUERY]: searchQuery } + : {}), + ...(resultsCount !== undefined + ? { [PredictEventProperties.RESULTS_COUNT]: resultsCount } + : {}), + ...(marketId ? { [PredictEventProperties.MARKET_ID]: marketId } : {}), + ...(marketTitle + ? { [PredictEventProperties.MARKET_TITLE]: marketTitle } + : {}), + }), + }, }; diff --git a/app/components/UI/Predict/views/PredictFeed/PredictFeed.test.tsx b/app/components/UI/Predict/views/PredictFeed/PredictFeed.test.tsx index 71ea76b0189..1eafea8b957 100644 --- a/app/components/UI/Predict/views/PredictFeed/PredictFeed.test.tsx +++ b/app/components/UI/Predict/views/PredictFeed/PredictFeed.test.tsx @@ -21,6 +21,21 @@ jest.mock('react-native-reanimated', () => { return Reanimated; }); +const mockTrackSearchInteracted = jest.fn(); + +jest.mock('../../../../../core/Engine', () => ({ + __esModule: true, + default: { + context: { + PredictController: { + trackSearchInteracted: ( + ...args: Parameters + ) => mockTrackSearchInteracted(...args), + }, + }, + }, +})); + import PredictFeed from './PredictFeed'; import { PredictBalance } from '../../components/PredictBalance'; diff --git a/app/components/UI/Predict/views/PredictFeed/PredictFeed.tsx b/app/components/UI/Predict/views/PredictFeed/PredictFeed.tsx index 04dfac120cb..72836c74478 100644 --- a/app/components/UI/Predict/views/PredictFeed/PredictFeed.tsx +++ b/app/components/UI/Predict/views/PredictFeed/PredictFeed.tsx @@ -79,6 +79,7 @@ import { strings } from '../../../../../../locales/i18n'; import { useTheme } from '../../../../../util/theme'; import { TraceName } from '../../../../../util/trace'; import Routes from '../../../../../constants/navigation/Routes'; +import Engine from '../../../../../core/Engine'; import { TabItem, TabsBar, @@ -658,6 +659,15 @@ const PredictFeed: React.FC = ({ withdrawUnavailableSheetRef.current?.onOpenBottomSheet(); }, []); + const handleShowSearch = useCallback(() => { + Engine.context.PredictController.trackSearchInteracted({ + interactionType: PredictEventValues.SEARCH_INTERACTION.OPENED, + predictFeedTab: tabs[activeIndex]?.key, + entryPoint: listEntryPoint, + }); + showSearch(); + }, [tabs, activeIndex, listEntryPoint, showSearch]); + const headerTopInset = hideHeader ? topInset : 0; return ( @@ -685,7 +695,7 @@ const PredictFeed: React.FC = ({ endButtonIconProps={[ { iconName: IconName.Search, - onPress: showSearch, + onPress: handleShowSearch, testID: PredictSearchSelectorsIDs.SEARCH_BUTTON, }, ]} @@ -731,6 +741,8 @@ const PredictFeed: React.FC = ({ onSearchChange={setSearchQuery} onClose={clearSearchAndClose} transactionActiveAbTests={transactionActiveAbTests} + predictFeedTab={tabs[activeIndex]?.key} + entryPoint={listEntryPoint} /> diff --git a/app/components/UI/Predict/views/PredictFeed/PredictFeed.view.test.tsx b/app/components/UI/Predict/views/PredictFeed/PredictFeed.view.test.tsx index d49a55fde11..93e0e48690a 100644 --- a/app/components/UI/Predict/views/PredictFeed/PredictFeed.view.test.tsx +++ b/app/components/UI/Predict/views/PredictFeed/PredictFeed.view.test.tsx @@ -7,8 +7,10 @@ * * Run with: yarn jest -c jest.config.view.js PredictFeed.view.test --runInBand --silent --coverage=false */ +import React from 'react'; import '../../../../../../tests/component-view/mocks'; import Engine from '../../../../../../app/core/Engine'; +import { useRoute } from '@react-navigation/native'; import { renderPredictFeedView, renderPredictFeedViewWithRoutes, @@ -19,6 +21,7 @@ import { waitFor, within, } from '@testing-library/react-native'; +import { Text } from 'react-native'; import { PredictMarketListSelectorsIDs, PredictSearchSelectorsIDs, @@ -97,6 +100,33 @@ const SEARCH_PLACEHOLDER = 'Search prediction markets'; const CANCEL_TEXT = 'Cancel'; const RETRY_TEXT = 'Retry'; +interface PredictRootRouteParams { + screen?: string; + params?: { + predictFeedTab?: string; + }; +} + +const PredictRootRouteParamsProbe = () => { + const route = useRoute<{ + key: string; + name: string; + params?: PredictRootRouteParams; + }>(); + const predictFeedTab = route.params?.params?.predictFeedTab ?? 'missing'; + + return ( + <> + + {route.params?.screen ?? 'unknown'} + + + {predictFeedTab} + + + ); +}; + const createPredictMarket = ({ id, category, @@ -199,6 +229,22 @@ describe('PredictFeed', () => { expect(await findByPlaceholderText(SEARCH_PLACEHOLDER)).toBeOnTheScreen(); }); + it('tracks the search opened event with the active feed tab and entry point', async () => { + const { getByTestId } = renderPredictFeedView(); + + fireEvent.press(getByTestId(PredictSearchSelectorsIDs.SEARCH_BUTTON)); + + expect( + Engine.context.PredictController.trackSearchInteracted, + ).toHaveBeenCalledWith( + expect.objectContaining({ + interactionType: 'opened', + predictFeedTab: expect.any(String), + entryPoint: 'predict_feed', + }), + ); + }); + it('calls PredictController.searchMarkets with the typed query after the user searches', async () => { const searchMarketsSpy = jest.spyOn( Engine.context.PredictController, @@ -317,6 +363,99 @@ describe('PredictFeed', () => { expect(within(resultCard).getByText(/Yes/)).toBeOnTheScreen(); expect(within(resultCard).getByText(/No/)).toBeOnTheScreen(); + // Assert — the `queried` event fires once with the resolved results count + await waitFor(() => { + expect( + Engine.context.PredictController.trackSearchInteracted, + ).toHaveBeenCalledWith( + expect.objectContaining({ + interactionType: 'queried', + searchQuery: 'bitcoin', + resultsCount: 1, + }), + ); + }); + + searchMarketsSpy.mockRestore(); + }); + + it('reports the server total match count in the queried event, not the visible page length', async () => { + // Arrange — one market on the first page, but 42 matches server-side. + // `marketData.length` (1) must not be used as the result count. + const searchMarketsSpy = jest.spyOn( + Engine.context.PredictController, + 'searchMarkets', + ); + searchMarketsSpy.mockResolvedValue({ + markets: [MOCK_PREDICT_MARKET], + totalResults: 42, + }); + const { getByTestId, findByPlaceholderText } = renderPredictFeedView(); + + // Act — user opens search and types a query + fireEvent.press(getByTestId(PredictSearchSelectorsIDs.SEARCH_BUTTON)); + const searchInput = await findByPlaceholderText(SEARCH_PLACEHOLDER); + fireEvent.changeText(searchInput, 'bitcoin'); + + // Assert — resultsCount is the server total (42), not the page length (1) + await waitFor(() => { + expect( + Engine.context.PredictController.trackSearchInteracted, + ).toHaveBeenCalledWith( + expect.objectContaining({ + interactionType: 'queried', + searchQuery: 'bitcoin', + resultsCount: 42, + }), + ); + }); + + searchMarketsSpy.mockRestore(); + }); + + it('tracks the same query again after the user clears and retypes it', async () => { + const searchMarketsSpy = jest.spyOn( + Engine.context.PredictController, + 'searchMarkets', + ); + searchMarketsSpy.mockResolvedValue({ + markets: [MOCK_PREDICT_MARKET], + totalResults: 1, + }); + + const trackSearchInteractedMock = Engine.context.PredictController + .trackSearchInteracted as jest.Mock; + const { getByTestId, findByPlaceholderText } = renderPredictFeedView(); + + fireEvent.press(getByTestId(PredictSearchSelectorsIDs.SEARCH_BUTTON)); + trackSearchInteractedMock.mockClear(); + + const searchInput = await findByPlaceholderText(SEARCH_PLACEHOLDER); + fireEvent.changeText(searchInput, 'bitcoin'); + + const getQueriedEvents = () => + trackSearchInteractedMock.mock.calls + .map(([args]) => args as { interactionType?: string }) + .filter((args) => args.interactionType === 'queried'); + + await waitFor(() => { + expect(getQueriedEvents()).toHaveLength(1); + }); + + fireEvent.press(getByTestId(PredictSearchSelectorsIDs.CLEAR_BUTTON)); + + await waitFor(() => { + expect(searchMarketsSpy).toHaveBeenCalledWith( + expect.objectContaining({ q: '' }), + ); + }); + + fireEvent.changeText(searchInput, 'bitcoin'); + + await waitFor(() => { + expect(getQueriedEvents()).toHaveLength(2); + }); + searchMarketsSpy.mockRestore(); }); @@ -332,7 +471,12 @@ describe('PredictFeed', () => { const { getByTestId, findByPlaceholderText, findByTestId } = renderPredictFeedViewWithRoutes({ - extraRoutes: [{ name: Routes.PREDICT.ROOT }], + extraRoutes: [ + { + name: Routes.PREDICT.ROOT, + Component: PredictRootRouteParamsProbe, + }, + ], }); fireEvent.press(getByTestId(PredictSearchSelectorsIDs.SEARCH_BUTTON)); @@ -350,6 +494,19 @@ describe('PredictFeed', () => { expect( await findByTestId(`route-${Routes.PREDICT.ROOT}`), ).toBeOnTheScreen(); + expect(await findByTestId('predict-root-tab-trending')).toBeOnTheScreen(); + + // Assert — the `result_clicked` event fires with the tapped market's id/title + expect( + Engine.context.PredictController.trackSearchInteracted, + ).toHaveBeenCalledWith( + expect.objectContaining({ + interactionType: 'result_clicked', + searchQuery: 'bitcoin', + marketId: MOCK_PREDICT_MARKET.id, + marketTitle: MOCK_PREDICT_MARKET.title, + }), + ); searchMarketsSpy.mockRestore(); }); diff --git a/app/components/UI/Predict/views/PredictHome/PredictHome.tsx b/app/components/UI/Predict/views/PredictHome/PredictHome.tsx index 08cdcc808a6..dc53cd07c45 100644 --- a/app/components/UI/Predict/views/PredictHome/PredictHome.tsx +++ b/app/components/UI/Predict/views/PredictHome/PredictHome.tsx @@ -7,7 +7,9 @@ import { useTailwind } from '@metamask/design-system-twrnc-preset'; import { Box, Text, TextVariant } from '@metamask/design-system-react-native'; import { strings } from '../../../../../../locales/i18n'; import Routes from '../../../../../constants/navigation/Routes'; +import Engine from '../../../../../core/Engine'; import { useTheme } from '../../../../../util/theme'; +import { PredictEventValues } from '../../constants/eventNames'; import { usePredictSearch } from '../../hooks/usePredictSearch'; import { usePredictStackedHeader } from '../../hooks/usePredictStackedHeader'; import { PredictNavigationParamList } from '../../types/navigation'; @@ -39,6 +41,11 @@ const PredictHome: React.FC = () => { const route = useRoute>(); const transactionActiveAbTests = route.params?.transactionActiveAbTests; + // Use the entry point the navigator passed; do NOT default. Falling back to a + // concrete value (e.g. `predict_feed`) would attribute home search engagement + // to the wrong surface. When unknown, the analytics mapper omits `entry_point` + // rather than bucketing it incorrectly. + const entryPoint = route.params?.entryPoint; const { scrollY, titleSectionHeight, onScroll, setTitleSectionHeight } = usePredictStackedHeader(); @@ -51,6 +58,14 @@ const PredictHome: React.FC = () => { clearSearchAndClose, } = usePredictSearch(); + const handleShowSearch = useCallback(() => { + Engine.context.PredictController.trackSearchInteracted({ + interactionType: PredictEventValues.SEARCH_INTERACTION.OPENED, + entryPoint, + }); + showSearch(); + }, [entryPoint, showSearch]); + const handleBackPress = useCallback(() => { if (navigation.canGoBack()) { navigation.goBack(); @@ -82,7 +97,7 @@ const PredictHome: React.FC = () => { scrollY={scrollY} titleSectionHeight={titleSectionHeight} onBack={handleBackPress} - onSearchPress={showSearch} + onSearchPress={handleShowSearch} /> { onSearchChange={setSearchQuery} onClose={clearSearchAndClose} transactionActiveAbTests={transactionActiveAbTests} + entryPoint={entryPoint} /> diff --git a/app/components/UI/Predict/views/PredictHome/PredictHome.view.test.tsx b/app/components/UI/Predict/views/PredictHome/PredictHome.view.test.tsx index 061701663a6..2f136e947ca 100644 --- a/app/components/UI/Predict/views/PredictHome/PredictHome.view.test.tsx +++ b/app/components/UI/Predict/views/PredictHome/PredictHome.view.test.tsx @@ -125,6 +125,37 @@ describe('PredictHome', () => { expect(await findByPlaceholderText(SEARCH_PLACEHOLDER)).toBeOnTheScreen(); }); + + it('tracks the search opened event when the user presses the search icon', async () => { + const { getByTestId } = renderPredictHomeView(); + + fireEvent.press(getByTestId(PredictSearchSelectorsIDs.SEARCH_BUTTON)); + + expect( + Engine.context.PredictController.trackSearchInteracted, + ).toHaveBeenCalledWith( + expect.objectContaining({ interactionType: 'opened' }), + ); + }); + + it('does not attribute the opened event to predict_feed when no entry point was provided', async () => { + // The redesigned home is rendered without an `entryPoint` route param, so + // the event must not fall back to the legacy `predict_feed` surface. + const { getByTestId } = renderPredictHomeView(); + + fireEvent.press(getByTestId(PredictSearchSelectorsIDs.SEARCH_BUTTON)); + + const trackSearchInteracted = Engine.context.PredictController + .trackSearchInteracted as jest.Mock; + const openedCall = trackSearchInteracted.mock.calls + .map( + ([args]) => args as { interactionType?: string; entryPoint?: string }, + ) + .find((args) => args.interactionType === 'opened'); + + expect(openedCall).toBeDefined(); + expect(openedCall?.entryPoint).toBeUndefined(); + }); }); describe('stacked header scroll wiring', () => { diff --git a/app/core/Analytics/MetaMetrics.events.ts b/app/core/Analytics/MetaMetrics.events.ts index 2d7669358ba..816722a2ce3 100644 --- a/app/core/Analytics/MetaMetrics.events.ts +++ b/app/core/Analytics/MetaMetrics.events.ts @@ -680,6 +680,7 @@ enum EVENT_NAME { PREDICT_GEO_BLOCKED_TRIGGERED = 'Geo Blocked Triggered', PREDICT_FEED_VIEWED = 'Predict Feed Viewed', PREDICT_BANNER_ACTION = 'Predict Banner Action', + PREDICT_SEARCH_INTERACTED = 'Predict Search Interacted', // Trending TRENDING_FEED_VIEWED = 'Trending Feed Viewed', @@ -1893,6 +1894,7 @@ const events = { ), PREDICT_FEED_VIEWED: generateOpt(EVENT_NAME.PREDICT_FEED_VIEWED), PREDICT_BANNER_ACTION: generateOpt(EVENT_NAME.PREDICT_BANNER_ACTION), + PREDICT_SEARCH_INTERACTED: generateOpt(EVENT_NAME.PREDICT_SEARCH_INTERACTED), TRENDING_FEED_VIEWED: generateOpt(EVENT_NAME.TRENDING_FEED_VIEWED), diff --git a/tests/component-view/mocks.ts b/tests/component-view/mocks.ts index 5509ee380e9..0cfc00a1b27 100644 --- a/tests/component-view/mocks.ts +++ b/tests/component-view/mocks.ts @@ -362,6 +362,7 @@ jest.mock('../../app/core/Engine', () => { trackMarketDetailsOpened: jest.fn(), trackGeoBlockTriggered: jest.fn(), trackActivityViewed: jest.fn(), + trackSearchInteracted: jest.fn(), trackPortfolioPositionsButtonTapped: jest.fn(), trackPortfolioTransactionInitiated: jest.fn(), trackPositionsScreenViewed: jest.fn(), From 54adf60b222de4be496efeb69f25b103c9a07ea7 Mon Sep 17 00:00:00 2001 From: Jyoti Puri Date: Wed, 10 Jun 2026 20:12:44 +0530 Subject: [PATCH 11/15] feat: change perps/predict page headers when user is navigating from money home page (#31463) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ## **Description** change perps/predict page headers when user is navigating from money home page ## **Changelog** CHANGELOG entry: ## **Related issues** Fixes: https://consensyssoftware.atlassian.net/browse/CONF-1516 ## **Manual testing steps** NA ## **Screenshots/Recordings** Screenshot 2026-06-10 at 3 01 38 PM Screenshot 2026-06-10 at 3 01 50 PM ## **Pre-merge author checklist** - [X] I've followed [MetaMask Contributor Docs](https://github.com/MetaMask/contributor-docs) and [MetaMask Mobile Coding Standards](https://github.com/MetaMask/metamask-mobile/blob/main/.github/guidelines/CODING_GUIDELINES.md). - [X] I've completed the PR template to the best of my ability - [X] I've included tests if applicable - [X] I've documented my code using [JSDoc](https://jsdoc.app/) format if applicable - [X] I've applied the right labels on the PR (see [labeling guidelines](https://github.com/MetaMask/metamask-mobile/blob/main/.github/guidelines/LABELING_GUIDELINES.md)). Not required for external contributors. #### Performance checks (if applicable) - [X] I've tested on Android - Ideally on a mid-range device; emulator is acceptable - [X] I've tested with a power user scenario - Use these [power-user SRPs](https://consensyssoftware.atlassian.net/wiki/spaces/TL1/pages/edit-v2/401401446401?draftShareId=9d77e1e1-4bdc-4be1-9ebb-ccd916988d93) to import wallets with many accounts and tokens - [X] I've instrumented key operations with Sentry traces for production performance metrics - See [`trace()`](/app/util/trace.ts) for usage and [`addToken`](/app/components/Views/AddAsset/components/AddCustomToken/AddCustomToken.tsx#L274) for an example For performance guidelines and tooling, see the [Performance Guide](https://consensyssoftware.atlassian.net/wiki/spaces/TL1/pages/400085549067/Performance+Guide+for+Engineers). ## **Pre-merge reviewer checklist** - [ ] I've manually tested the PR (e.g. pull and build branch, run the app, test code being changed). - [ ] I confirm that this PR addresses all acceptance criteria described in the ticket it closes and includes the necessary testing evidence such as recordings and or screenshots. --- > [!NOTE] > **Low Risk** > Navbar copy only, driven by existing navigation params; no payment or transaction logic changes. > > **Overview** > **Perps and Predict deposit confirmation screens** now pick the navbar title from route params: when `payWithOption` is `MoneyAccount` (money home entry), the header shows **Transfer to Perps** or **Transfer to Predictions** instead of the usual deposit titles. > > New English strings support those labels. **Unit tests** cover default vs money-account titles plus existing behavior (token registration, `CustomAmountInfo`, money-account payment override). > > Reviewed by [Cursor Bugbot](https://cursor.com/bugbot) for commit 8917eadf4f0877db7fc26cab0668cced7ab43151. Bugbot is set up for automated code reviews on this repo. Configure [here](https://www.cursor.com/dashboard/bugbot). --- .../perps-deposit-info.test.tsx | 82 +++++++++++++++++++ .../perps-deposit-info/perps-deposit-info.tsx | 13 ++- .../predict-deposit-info.test.tsx | 82 +++++++++++++++++++ .../predict-deposit-info.tsx | 13 ++- locales/languages/en.json | 2 + 5 files changed, 190 insertions(+), 2 deletions(-) create mode 100644 app/components/Views/confirmations/components/info/perps-deposit-info/perps-deposit-info.test.tsx create mode 100644 app/components/Views/confirmations/components/info/predict-deposit-info/predict-deposit-info.test.tsx diff --git a/app/components/Views/confirmations/components/info/perps-deposit-info/perps-deposit-info.test.tsx b/app/components/Views/confirmations/components/info/perps-deposit-info/perps-deposit-info.test.tsx new file mode 100644 index 00000000000..0fe271638b1 --- /dev/null +++ b/app/components/Views/confirmations/components/info/perps-deposit-info/perps-deposit-info.test.tsx @@ -0,0 +1,82 @@ +import { CHAIN_IDS } from '@metamask/transaction-controller'; +import React from 'react'; +import { render } from '@testing-library/react-native'; +import { strings } from '../../../../../../../locales/i18n'; +import { ARBITRUM_USDC, PERPS_CURRENCY } from '../../../constants/perps'; +import { useAddToken } from '../../../hooks/tokens/useAddToken'; +import useNavbar from '../../../hooks/ui/useNavbar'; +import { useMoneyAccountPaymentOverride } from '../../../hooks/pay/useMoneyAccountPaymentOverride'; +import { useParams } from '../../../../../../util/navigation/navUtils'; +import { PayWithOption } from '../../confirm/confirm-component'; +import { CustomAmountInfo } from '../custom-amount-info'; +import { PerpsDepositInfo } from './perps-deposit-info'; + +jest.mock('../../../hooks/ui/useNavbar'); +jest.mock('../../../hooks/tokens/useAddToken'); +jest.mock('../../../hooks/pay/useMoneyAccountPaymentOverride'); +jest.mock('../../../../../../util/navigation/navUtils', () => ({ + ...jest.requireActual('../../../../../../util/navigation/navUtils'), + useParams: jest.fn(), +})); +jest.mock('../custom-amount-info', () => ({ + CustomAmountInfo: jest.fn(() => null), +})); + +describe('PerpsDepositInfo', () => { + const mockUseNavbar = jest.mocked(useNavbar); + const mockUseAddToken = jest.mocked(useAddToken); + const mockUseParams = jest.mocked(useParams); + const mockCustomAmountInfo = jest.mocked(CustomAmountInfo); + + beforeEach(() => { + jest.clearAllMocks(); + mockUseParams.mockReturnValue({}); + }); + + it('sets navbar title to default perps deposit title', () => { + render(); + + expect(mockUseNavbar).toHaveBeenCalledWith( + strings('confirm.title.perps_deposit'), + ); + }); + + it('sets navbar title to "Transfer to Perps" when payWithOption is MoneyAccount', () => { + mockUseParams.mockReturnValue({ + payWithOption: PayWithOption.MoneyAccount, + }); + + render(); + + expect(mockUseNavbar).toHaveBeenCalledWith( + strings('perps.transfer_to_perps'), + ); + }); + + it('registers Arbitrum USDC token', () => { + render(); + + expect(mockUseAddToken).toHaveBeenCalledWith({ + chainId: CHAIN_IDS.ARBITRUM, + decimals: ARBITRUM_USDC.decimals, + name: ARBITRUM_USDC.name, + symbol: ARBITRUM_USDC.symbol, + tokenAddress: ARBITRUM_USDC.address, + }); + }); + + it('renders CustomAmountInfo with perps currency and hasMax', () => { + render(); + + expect(mockCustomAmountInfo).toHaveBeenCalledWith( + expect.objectContaining({ currency: PERPS_CURRENCY, hasMax: true }), + undefined, + ); + }); + + it('calls useMoneyAccountPaymentOverride', () => { + render(); + + expect(useMoneyAccountPaymentOverride).toHaveBeenCalled(); + }); +}); diff --git a/app/components/Views/confirmations/components/info/perps-deposit-info/perps-deposit-info.tsx b/app/components/Views/confirmations/components/info/perps-deposit-info/perps-deposit-info.tsx index 05f780e0c14..d663b8fd1c0 100644 --- a/app/components/Views/confirmations/components/info/perps-deposit-info/perps-deposit-info.tsx +++ b/app/components/Views/confirmations/components/info/perps-deposit-info/perps-deposit-info.tsx @@ -6,9 +6,20 @@ import { ARBITRUM_USDC, PERPS_CURRENCY } from '../../../constants/perps'; import { useAddToken } from '../../../hooks/tokens/useAddToken'; import { CHAIN_IDS } from '@metamask/transaction-controller'; import { useMoneyAccountPaymentOverride } from '../../../hooks/pay/useMoneyAccountPaymentOverride'; +import { useParams } from '../../../../../../util/navigation/navUtils'; +import { + ConfirmationParams, + PayWithOption, +} from '../../confirm/confirm-component'; export function PerpsDepositInfo() { - useNavbar(strings('confirm.title.perps_deposit')); + const { payWithOption } = useParams({}); + const title = + payWithOption === PayWithOption.MoneyAccount + ? strings('perps.transfer_to_perps') + : strings('confirm.title.perps_deposit'); + + useNavbar(title); useMoneyAccountPaymentOverride(); useAddToken({ diff --git a/app/components/Views/confirmations/components/info/predict-deposit-info/predict-deposit-info.test.tsx b/app/components/Views/confirmations/components/info/predict-deposit-info/predict-deposit-info.test.tsx new file mode 100644 index 00000000000..9c4f1f15ba9 --- /dev/null +++ b/app/components/Views/confirmations/components/info/predict-deposit-info/predict-deposit-info.test.tsx @@ -0,0 +1,82 @@ +import { CHAIN_IDS } from '@metamask/transaction-controller'; +import React from 'react'; +import { render } from '@testing-library/react-native'; +import { strings } from '../../../../../../../locales/i18n'; +import { POLYGON_PUSD, PREDICT_CURRENCY } from '../../../constants/predict'; +import { useAddToken } from '../../../hooks/tokens/useAddToken'; +import useNavbar from '../../../hooks/ui/useNavbar'; +import { useMoneyAccountPaymentOverride } from '../../../hooks/pay/useMoneyAccountPaymentOverride'; +import { useParams } from '../../../../../../util/navigation/navUtils'; +import { PayWithOption } from '../../confirm/confirm-component'; +import { CustomAmountInfo } from '../custom-amount-info'; +import { PredictDepositInfo } from './predict-deposit-info'; + +jest.mock('../../../hooks/ui/useNavbar'); +jest.mock('../../../hooks/tokens/useAddToken'); +jest.mock('../../../hooks/pay/useMoneyAccountPaymentOverride'); +jest.mock('../../../../../../util/navigation/navUtils', () => ({ + ...jest.requireActual('../../../../../../util/navigation/navUtils'), + useParams: jest.fn(), +})); +jest.mock('../custom-amount-info', () => ({ + CustomAmountInfo: jest.fn(() => null), +})); + +describe('PredictDepositInfo', () => { + const mockUseNavbar = jest.mocked(useNavbar); + const mockUseAddToken = jest.mocked(useAddToken); + const mockUseParams = jest.mocked(useParams); + const mockCustomAmountInfo = jest.mocked(CustomAmountInfo); + + beforeEach(() => { + jest.clearAllMocks(); + mockUseParams.mockReturnValue({}); + }); + + it('sets navbar title to default predict deposit title', () => { + render(); + + expect(mockUseNavbar).toHaveBeenCalledWith( + strings('confirm.title.predict_deposit'), + ); + }); + + it('sets navbar title to "Transfer to Predictions" when payWithOption is MoneyAccount', () => { + mockUseParams.mockReturnValue({ + payWithOption: PayWithOption.MoneyAccount, + }); + + render(); + + expect(mockUseNavbar).toHaveBeenCalledWith( + strings('predict.transfer_to_predictions'), + ); + }); + + it('registers Polygon pUSD token', () => { + render(); + + expect(mockUseAddToken).toHaveBeenCalledWith({ + chainId: CHAIN_IDS.POLYGON, + decimals: POLYGON_PUSD.decimals, + name: POLYGON_PUSD.name, + symbol: POLYGON_PUSD.symbol, + tokenAddress: POLYGON_PUSD.address, + }); + }); + + it('renders CustomAmountInfo with predict currency', () => { + render(); + + expect(mockCustomAmountInfo).toHaveBeenCalledWith( + expect.objectContaining({ currency: PREDICT_CURRENCY }), + undefined, + ); + }); + + it('calls useMoneyAccountPaymentOverride', () => { + render(); + + expect(useMoneyAccountPaymentOverride).toHaveBeenCalled(); + }); +}); diff --git a/app/components/Views/confirmations/components/info/predict-deposit-info/predict-deposit-info.tsx b/app/components/Views/confirmations/components/info/predict-deposit-info/predict-deposit-info.tsx index ab82debcdc6..f4ba844b15e 100644 --- a/app/components/Views/confirmations/components/info/predict-deposit-info/predict-deposit-info.tsx +++ b/app/components/Views/confirmations/components/info/predict-deposit-info/predict-deposit-info.tsx @@ -6,9 +6,20 @@ import { POLYGON_PUSD, PREDICT_CURRENCY } from '../../../constants/predict'; import { useAddToken } from '../../../hooks/tokens/useAddToken'; import { CHAIN_IDS } from '@metamask/transaction-controller'; import { useMoneyAccountPaymentOverride } from '../../../hooks/pay/useMoneyAccountPaymentOverride'; +import { useParams } from '../../../../../../util/navigation/navUtils'; +import { + ConfirmationParams, + PayWithOption, +} from '../../confirm/confirm-component'; export function PredictDepositInfo() { - useNavbar(strings('confirm.title.predict_deposit')); + const { payWithOption } = useParams({}); + const title = + payWithOption === PayWithOption.MoneyAccount + ? strings('predict.transfer_to_predictions') + : strings('confirm.title.predict_deposit'); + + useNavbar(title); useMoneyAccountPaymentOverride(); useAddToken({ diff --git a/locales/languages/en.json b/locales/languages/en.json index 360d1dc79c9..9373e6e6ff2 100644 --- a/locales/languages/en.json +++ b/locales/languages/en.json @@ -1142,6 +1142,7 @@ "perps": { "basic_functionality_disabled_title": "Perps is not available", "title": "Perps", + "transfer_to_perps": "Transfer to Perps", "perps_trading": "Perps trading", "perp_account_balance": "Perp account balance", "manage_balance": "Manage balance", @@ -2308,6 +2309,7 @@ }, "predict": { "title": "MetaMask Predictions", + "transfer_to_predictions": "Transfer to Predictions", "select_predict_account": "Select predict account", "search_account": "Search an account", "home": { From 69603e199a42539b8f1279a7ea21dfd1805bc6cc Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Caain=C3=A3=20Jeronimo?= Date: Wed, 10 Jun 2026 12:29:02 -0300 Subject: [PATCH 12/15] feat(predict): integrate portfolio module into redesigned Predict homepage (PRED-906) (#31226) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ## **Description** Integrates the reusable PRED-835 `PredictPortfolioModule` into the redesigned Predict homepage (`PredictHome`), replacing the temporary mock placeholder that previously sat at the top of the feed. **Why:** PRED-906 (under the PRED-834 IA & Navigation overhaul) requires the redesigned homepage to render the real portfolio module — value, P&L, available balance, and the Positions / Add funds / Withdraw / Claim actions — at the top of the single-scroll feed. **What changed:** - **Relocated** the module from `components/PredictPortfolio` into `views/PredictHome/components/PredictPortfolio`, since the redesigned homepage is its only consumer (pure `git mv` + import-depth fixes, no behavior change). - **Wired** the real `PredictPortfolioModule` into `PredictHome`, removing the mock placeholder component and its unused `portfolio_module_placeholder` string. - Gating stays entirely at the **route level** via `predictHomeRedesign.enabled` — the redesigned home always shows the module, while the legacy `PredictFeed` (balance card) renders when the redesign flag is off. No in-view flag or duplicate "Predictions" title. - **Fixed** the portfolio Positions button, which still used an old temporary fallback (navigating to `PREDICT.MARKET_LIST`); it now routes to `Routes.PREDICT.POSITIONS`, matching the working `PredictBalance` button. - Minor spacing fix: title section bottom padding `pb-4` → `pb-2` to match Figma. ## **Changelog** CHANGELOG entry: null ## **Related issues** Fixes: https://consensyssoftware.atlassian.net/browse/PRED-906 ## **Manual testing steps** ```gherkin Feature: Predict homepage portfolio module Scenario: user views the portfolio module on the redesigned Predict homepage Given the predictHomeRedesign feature flag is enabled When the user opens the Predict homepage Then the portfolio module renders at the top of the feed And it shows portfolio value, P&L line, available balance, and the action buttons And no duplicate "Predictions" title is shown Scenario: user opens the Positions screen from the portfolio Given the portfolio module is visible on the Predict homepage When the user taps the "Positions" button Then the Predict Positions screen opens Scenario: portfolio actions behave as in the existing feed When the user taps Add funds, Withdraw, or Claim Then the action behaves the same as the PRED-835 current-feed integration Scenario: fallback when the redesign flag is off Given the predictHomeRedesign feature flag is disabled When the user opens the Predict tab Then the legacy PredictFeed renders with the existing balance card ``` ## **Screenshots/Recordings** ### **Before** ### **After** Screenshot 2026-06-08 at 15 20 44 ## **Pre-merge author checklist** - [x] I've followed [MetaMask Contributor Docs](https://github.com/MetaMask/contributor-docs) and [MetaMask Mobile Coding Standards](https://github.com/MetaMask/metamask-mobile/blob/main/.github/guidelines/CODING_GUIDELINES.md). - [x] I've completed the PR template to the best of my ability - [x] I've included tests if applicable - [x] I've documented my code using [JSDoc](https://jsdoc.app/) format if applicable - [x] I've applied the right labels on the PR (see [labeling guidelines](https://github.com/MetaMask/metamask-mobile/blob/main/.github/guidelines/LABELING_GUIDELINES.md)). Not required for external contributors. #### Performance checks (if applicable) - [x] I've tested on Android - Ideally on a mid-range device; emulator is acceptable - [x] I've tested with a power user scenario - Use these [power-user SRPs](https://consensyssoftware.atlassian.net/wiki/spaces/TL1/pages/edit-v2/401401446401?draftShareId=9d77e1e1-4bdc-4be1-9ebb-ccd916988d93) to import wallets with many accounts and tokens - [x] I've instrumented key operations with Sentry traces for production performance metrics - See [`trace()`](/app/util/trace.ts) for usage and [`addToken`](/app/components/Views/AddAsset/components/AddCustomToken/AddCustomToken.tsx#L274) for an example For performance guidelines and tooling, see the [Performance Guide](https://consensyssoftware.atlassian.net/wiki/spaces/TL1/pages/400085549067/Performance+Guide+for+Engineers). ## **Pre-merge reviewer checklist** - [ ] I've manually tested the PR (e.g. pull and build branch, run the app, test code being changed). - [ ] I confirm that this PR addresses all acceptance criteria described in the ticket it closes and includes the necessary testing evidence such as recordings and or screenshots. 🤖 Generated with [Claude Code](https://claude.com/claude-code) --- > [!NOTE] > **Low Risk** > UI and navigation changes behind the predict home redesign path; Positions is no longer redirected when the portfolio flag is off, which slightly broadens access to that screen. > > **Overview** > Replaces the redesigned **Predict home** placeholder portfolio block with the real **`PredictPortfolioModule`** (value, P&L, actions) and hooks **deposit-wallet withdraw** to **`PredictWithdrawUnavailableSheet`** on the home shell. > > **Positions** from the module now navigates to **`Routes.PREDICT.POSITIONS`** instead of the temporary market-list fallback. The stack **drops the portfolio-flag gate** on the Positions route (`PredictPositionsRoute` removed); **`PredictPositionsView`** is registered directly, so deep links to Positions work even when the portfolio flag is off. > > Removes the mock **`PredictPortfolioModule`** placeholder folder and the unused **`portfolio_module_placeholder`** copy; module **`testID`** aligns with **`PredictHomeSelectorsIDs.PORTFOLIO_MODULE`**. Tests follow the new navigation and routing behavior. > > Reviewed by [Cursor Bugbot](https://cursor.com/bugbot) for commit 6860016527a21091bc94fda1bb389ffecc5a104b. Bugbot is set up for automated code reviews on this repo. Configure [here](https://www.cursor.com/dashboard/bugbot). --------- Co-authored-by: Claude Opus 4.8 --- .../UI/Predict/routes/index.test.tsx | 11 +--- app/components/UI/Predict/routes/index.tsx | 37 +----------- .../Predict/views/PredictHome/PredictHome.tsx | 22 +++++-- .../PredictHome/PredictHome.view.test.tsx | 11 ++-- .../PredictPortfolio.testIds.ts | 0 .../PredictPortfolioAction.tsx | 0 .../PredictPortfolioActions.tsx | 2 +- .../PredictPortfolioModule.test.tsx | 57 +++++-------------- .../PredictPortfolioModule.tsx | 26 ++++----- .../PredictPortfolioSummary.test.tsx | 4 +- .../PredictPortfolioSummary.tsx | 10 ++-- .../components/PredictPortfolio/index.ts | 0 .../PredictPortfolioModule.tsx | 32 ----------- .../PredictPortfolioModule/index.ts | 1 - locales/languages/en.json | 1 - 15 files changed, 65 insertions(+), 149 deletions(-) rename app/components/UI/Predict/{ => views/PredictHome}/components/PredictPortfolio/PredictPortfolio.testIds.ts (100%) rename app/components/UI/Predict/{ => views/PredictHome}/components/PredictPortfolio/PredictPortfolioAction.tsx (100%) rename app/components/UI/Predict/{ => views/PredictHome}/components/PredictPortfolio/PredictPortfolioActions.tsx (95%) rename app/components/UI/Predict/{ => views/PredictHome}/components/PredictPortfolio/PredictPortfolioModule.test.tsx (85%) rename app/components/UI/Predict/{ => views/PredictHome}/components/PredictPortfolio/PredictPortfolioModule.tsx (85%) rename app/components/UI/Predict/{ => views/PredictHome}/components/PredictPortfolio/PredictPortfolioSummary.test.tsx (96%) rename app/components/UI/Predict/{ => views/PredictHome}/components/PredictPortfolio/PredictPortfolioSummary.tsx (92%) rename app/components/UI/Predict/{ => views/PredictHome}/components/PredictPortfolio/index.ts (100%) delete mode 100644 app/components/UI/Predict/views/PredictHome/components/PredictPortfolioModule/PredictPortfolioModule.tsx delete mode 100644 app/components/UI/Predict/views/PredictHome/components/PredictPortfolioModule/index.ts diff --git a/app/components/UI/Predict/routes/index.test.tsx b/app/components/UI/Predict/routes/index.test.tsx index f3e30a2e711..6378480ef71 100644 --- a/app/components/UI/Predict/routes/index.test.tsx +++ b/app/components/UI/Predict/routes/index.test.tsx @@ -1,5 +1,5 @@ import React from 'react'; -import { render, screen, act, waitFor } from '@testing-library/react-native'; +import { render, screen, act } from '@testing-library/react-native'; import { NavigationContainer, NavigationContainerRef, @@ -207,7 +207,7 @@ describe('PredictScreenStack', () => { expect(screen.getByTestId('predict-positions-view')).toBeOnTheScreen(); }); - it('returns to market list when POSITIONS screen is disabled', async () => { + it('navigates to POSITIONS screen when portfolio flag is disabled', async () => { mockPredictPortfolioEnabled = false; renderWithNavigation(); @@ -215,12 +215,7 @@ describe('PredictScreenStack', () => { navigationRef.current?.navigate(Routes.PREDICT.POSITIONS); }); - await waitFor(() => { - expect(navigationRef.current?.getCurrentRoute()?.name).toBe( - Routes.PREDICT.MARKET_LIST, - ); - }); - expect(screen.queryByTestId('predict-positions-view')).toBeNull(); + expect(screen.getByTestId('predict-positions-view')).toBeOnTheScreen(); }); it('navigates to BUY_PREVIEW with PredictBuyPreview when payWithAnyToken is off', async () => { diff --git a/app/components/UI/Predict/routes/index.tsx b/app/components/UI/Predict/routes/index.tsx index a097693e88c..cead671f0a1 100644 --- a/app/components/UI/Predict/routes/index.tsx +++ b/app/components/UI/Predict/routes/index.tsx @@ -1,6 +1,5 @@ import { createNativeStackNavigator } from '@react-navigation/native-stack'; -import { NavigationProp, useNavigation } from '@react-navigation/native'; -import React, { useEffect } from 'react'; +import React from 'react'; import { strings } from '../../../../../locales/i18n'; import Routes from '../../../../constants/navigation/Routes'; import { @@ -25,41 +24,11 @@ import { PredictPreviewSheetProvider } from '../contexts'; import PredictBuyPreview from '../views/PredictBuyPreview/PredictBuyPreview'; import PredictBuyWithAnyToken from '../views/PredictBuyWithAnyToken'; import PredictSellPreview from '../views/PredictSellPreview/PredictSellPreview'; -import { - selectPredictPortfolioEnabledFlag, - selectPredictWithAnyTokenEnabledFlag, -} from '../selectors/featureFlags'; +import { selectPredictWithAnyTokenEnabledFlag } from '../selectors/featureFlags'; const Stack = createNativeStackNavigator(); const ModalStack = createNativeStackNavigator(); -const PredictPositionsRoute = () => { - const navigation = - useNavigation>(); - const predictPortfolioEnabled = useSelector( - selectPredictPortfolioEnabledFlag, - ); - - useEffect(() => { - if (predictPortfolioEnabled) { - return; - } - - if (navigation.canGoBack()) { - navigation.goBack(); - return; - } - - navigation.navigate(Routes.PREDICT.MARKET_LIST); - }, [navigation, predictPortfolioEnabled]); - - if (!predictPortfolioEnabled) { - return null; - } - - return ; -}; - const PredictModalStack = () => { const emptyNavHeaderOptions = useEmptyNavHeaderForConfirmations(); @@ -131,7 +100,7 @@ const PredictScreenStack = () => { { [setTitleSectionHeight], ); + const withdrawUnavailableSheetRef = + useRef(null); + const handleDepositWalletWithdrawPress = useCallback(() => { + withdrawUnavailableSheetRef.current?.onOpenBottomSheet(); + }, []); + return ( { { - + @@ -137,6 +148,9 @@ const PredictHome: React.FC = () => { entryPoint={entryPoint} /> + + + ); }; diff --git a/app/components/UI/Predict/views/PredictHome/PredictHome.view.test.tsx b/app/components/UI/Predict/views/PredictHome/PredictHome.view.test.tsx index 2f136e947ca..5e4f7c6e30b 100644 --- a/app/components/UI/Predict/views/PredictHome/PredictHome.view.test.tsx +++ b/app/components/UI/Predict/views/PredictHome/PredictHome.view.test.tsx @@ -21,6 +21,7 @@ import { PredictSearchSelectorsIDs, } from '../../Predict.testIds'; import { PREDICT_HEADER_STACKED_TEST_IDS } from '../../components/PredictHeaderStacked'; +import { PREDICT_PORTFOLIO_TEST_IDS } from './components/PredictPortfolio'; import { MOCK_PREDICT_MARKET } from '../../../../../../tests/component-view/fixtures/predict'; const SEARCH_PLACEHOLDER = 'Search prediction markets'; @@ -86,11 +87,13 @@ describe('PredictHome', () => { describe('shell composition', () => { it('renders the large "Predictions" title', async () => { - const { findByTestId } = renderPredictHomeView(); + const { getByTestId } = renderPredictHomeView(); - expect( - await findByTestId(PredictHomeSelectorsIDs.TITLE), - ).toHaveTextContent(PREDICTIONS_TITLE); + await waitFor(() => { + expect(getByTestId(PredictHomeSelectorsIDs.TITLE)).toHaveTextContent( + PREDICTIONS_TITLE, + ); + }); }); it('composes the section placeholders in Figma order', async () => { diff --git a/app/components/UI/Predict/components/PredictPortfolio/PredictPortfolio.testIds.ts b/app/components/UI/Predict/views/PredictHome/components/PredictPortfolio/PredictPortfolio.testIds.ts similarity index 100% rename from app/components/UI/Predict/components/PredictPortfolio/PredictPortfolio.testIds.ts rename to app/components/UI/Predict/views/PredictHome/components/PredictPortfolio/PredictPortfolio.testIds.ts diff --git a/app/components/UI/Predict/components/PredictPortfolio/PredictPortfolioAction.tsx b/app/components/UI/Predict/views/PredictHome/components/PredictPortfolio/PredictPortfolioAction.tsx similarity index 100% rename from app/components/UI/Predict/components/PredictPortfolio/PredictPortfolioAction.tsx rename to app/components/UI/Predict/views/PredictHome/components/PredictPortfolio/PredictPortfolioAction.tsx diff --git a/app/components/UI/Predict/components/PredictPortfolio/PredictPortfolioActions.tsx b/app/components/UI/Predict/views/PredictHome/components/PredictPortfolio/PredictPortfolioActions.tsx similarity index 95% rename from app/components/UI/Predict/components/PredictPortfolio/PredictPortfolioActions.tsx rename to app/components/UI/Predict/views/PredictHome/components/PredictPortfolio/PredictPortfolioActions.tsx index d960419eb23..b866126d160 100644 --- a/app/components/UI/Predict/components/PredictPortfolio/PredictPortfolioActions.tsx +++ b/app/components/UI/Predict/views/PredictHome/components/PredictPortfolio/PredictPortfolioActions.tsx @@ -1,6 +1,6 @@ import React from 'react'; import { Box, IconName } from '@metamask/design-system-react-native'; -import { strings } from '../../../../../../locales/i18n'; +import { strings } from '../../../../../../../../locales/i18n'; import PredictPortfolioAction from './PredictPortfolioAction'; import { PREDICT_PORTFOLIO_TEST_IDS } from './PredictPortfolio.testIds'; diff --git a/app/components/UI/Predict/components/PredictPortfolio/PredictPortfolioModule.test.tsx b/app/components/UI/Predict/views/PredictHome/components/PredictPortfolio/PredictPortfolioModule.test.tsx similarity index 85% rename from app/components/UI/Predict/components/PredictPortfolio/PredictPortfolioModule.test.tsx rename to app/components/UI/Predict/views/PredictHome/components/PredictPortfolio/PredictPortfolioModule.test.tsx index 5c0c153a81b..a5af3281f29 100644 --- a/app/components/UI/Predict/components/PredictPortfolio/PredictPortfolioModule.test.tsx +++ b/app/components/UI/Predict/views/PredictHome/components/PredictPortfolio/PredictPortfolioModule.test.tsx @@ -1,10 +1,11 @@ import React from 'react'; import { fireEvent, screen } from '@testing-library/react-native'; -import Routes from '../../../../../constants/navigation/Routes'; -import Engine from '../../../../../core/Engine'; -import renderWithProvider from '../../../../../util/test/renderWithProvider'; -import { PredictEventValues } from '../../constants/eventNames'; -import { usePredictPortfolio } from '../../hooks/usePredictPortfolio'; +import Routes from '../../../../../../../constants/navigation/Routes'; +import Engine from '../../../../../../../core/Engine'; +import renderWithProvider from '../../../../../../../util/test/renderWithProvider'; +import { PredictHomeSelectorsIDs } from '../../../../Predict.testIds'; +import { PredictEventValues } from '../../../../constants/eventNames'; +import { usePredictPortfolio } from '../../../../hooks/usePredictPortfolio'; import PredictPortfolioModule from './PredictPortfolioModule'; import { PREDICT_PORTFOLIO_TEST_IDS } from './PredictPortfolio.testIds'; @@ -39,28 +40,28 @@ jest.mock('react-redux', () => ({ }), })); -jest.mock('../../../../../selectors/preferencesController', () => ({ +jest.mock('../../../../../../../selectors/preferencesController', () => ({ selectPrivacyMode: 'selectPrivacyMode', })); jest.mock( - '../../../../../selectors/featureFlagController/confirmations', + '../../../../../../../selectors/featureFlagController/confirmations', () => ({ selectMetaMaskPayFlags: 'selectMetaMaskPayFlags', }), ); -jest.mock('../../hooks/usePredictActionGuard', () => ({ +jest.mock('../../../../hooks/usePredictActionGuard', () => ({ usePredictActionGuard: () => ({ executeGuardedAction: mockExecuteGuardedAction, }), })); -jest.mock('../../hooks/usePredictPortfolio', () => ({ +jest.mock('../../../../hooks/usePredictPortfolio', () => ({ usePredictPortfolio: jest.fn(), })); -jest.mock('../../../../../../locales/i18n', () => ({ +jest.mock('../../../../../../../../locales/i18n', () => ({ strings: jest.fn((key: string, params?: Record) => { const mockStrings: Record = { 'predict.claim_amount_text': `Claim $${params?.amount}`, @@ -150,7 +151,7 @@ describe('PredictPortfolioModule', () => { renderWithProvider(); expect( - screen.getByTestId(PREDICT_PORTFOLIO_TEST_IDS.MODULE), + screen.getByTestId(PredictHomeSelectorsIDs.PORTFOLIO_MODULE), ).toBeOnTheScreen(); expect(screen.getByText('$0.00')).toBeOnTheScreen(); expect(screen.getByText('Positions')).toBeOnTheScreen(); @@ -382,43 +383,13 @@ describe('PredictPortfolioModule', () => { ); }); - it('does not track claim initiated when the action guard short-circuits', () => { - mockUsePredictPortfolio.mockReturnValue( - createPortfolio({ - claimableAmount: 46.35, - hasClaimableWinnings: true, - }), - ); - mockExecuteGuardedAction.mockImplementationOnce(() => undefined); - - renderWithProvider(); - - fireEvent.press( - screen.getByTestId(PREDICT_PORTFOLIO_TEST_IDS.CLAIM_BUTTON), - ); - - expect(mockExecuteGuardedAction).toHaveBeenCalledWith( - expect.any(Function), - { attemptedAction: PredictEventValues.ATTEMPTED_ACTION.CLAIM }, - ); - expect(mockClaim).not.toHaveBeenCalled(); - expect(mockTrackPortfolioTransactionInitiated).not.toHaveBeenCalled(); - }); - - it('uses the temporary Positions fallback until the route lands', () => { + it('navigates to the Predict Positions screen by default', () => { renderWithProvider(); fireEvent.press( screen.getByTestId(PREDICT_PORTFOLIO_TEST_IDS.ACTION_POSITIONS), ); - expect(mockNavigate).toHaveBeenCalledWith(Routes.PREDICT.MARKET_LIST, { - entryPoint: PredictEventValues.ENTRY_POINT.HOMEPAGE_POSITIONS, - }); - expect(mockTrackPortfolioPositionsButtonTapped).toHaveBeenCalledWith( - expectedPortfolioContext({ - entryPoint: PredictEventValues.ENTRY_POINT.HOMEPAGE_POSITIONS, - }), - ); + expect(mockNavigate).toHaveBeenCalledWith(Routes.PREDICT.POSITIONS); }); }); diff --git a/app/components/UI/Predict/components/PredictPortfolio/PredictPortfolioModule.tsx b/app/components/UI/Predict/views/PredictHome/components/PredictPortfolio/PredictPortfolioModule.tsx similarity index 85% rename from app/components/UI/Predict/components/PredictPortfolio/PredictPortfolioModule.tsx rename to app/components/UI/Predict/views/PredictHome/components/PredictPortfolio/PredictPortfolioModule.tsx index 60580b5afdc..520be9a3ae7 100644 --- a/app/components/UI/Predict/components/PredictPortfolio/PredictPortfolioModule.tsx +++ b/app/components/UI/Predict/views/PredictHome/components/PredictPortfolio/PredictPortfolioModule.tsx @@ -2,18 +2,19 @@ import React, { useCallback, useMemo } from 'react'; import { NavigationProp, useNavigation } from '@react-navigation/native'; import { Box } from '@metamask/design-system-react-native'; import { useSelector } from 'react-redux'; -import Routes from '../../../../../constants/navigation/Routes'; -import Engine from '../../../../../core/Engine'; -import { selectMetaMaskPayFlags } from '../../../../../selectors/featureFlagController/confirmations'; -import { selectPrivacyMode } from '../../../../../selectors/preferencesController'; -import { PredictEventValues } from '../../constants/eventNames'; -import { usePredictActionGuard } from '../../hooks/usePredictActionGuard'; -import { usePredictPortfolio } from '../../hooks/usePredictPortfolio'; -import type { PredictNavigationParamList } from '../../types/navigation'; -import PredictClaimButton from '../PredictActionButtons/PredictClaimButton'; +import Routes from '../../../../../../../constants/navigation/Routes'; +import Engine from '../../../../../../../core/Engine'; +import { selectMetaMaskPayFlags } from '../../../../../../../selectors/featureFlagController/confirmations'; +import { selectPrivacyMode } from '../../../../../../../selectors/preferencesController'; +import { PredictEventValues } from '../../../../constants/eventNames'; +import { usePredictActionGuard } from '../../../../hooks/usePredictActionGuard'; +import { usePredictPortfolio } from '../../../../hooks/usePredictPortfolio'; +import type { PredictNavigationParamList } from '../../../../types/navigation'; +import PredictClaimButton from '../../../../components/PredictActionButtons/PredictClaimButton'; import PredictPortfolioActions from './PredictPortfolioActions'; import PredictPortfolioSummary from './PredictPortfolioSummary'; import { PREDICT_PORTFOLIO_TEST_IDS } from './PredictPortfolio.testIds'; +import { PredictHomeSelectorsIDs } from '../../../../Predict.testIds'; export interface PredictPortfolioModuleProps { onDepositWalletWithdrawPress?: () => void; @@ -81,10 +82,7 @@ const PredictPortfolioModule: React.FC = ({ return; } - // Temporary fallback until the dedicated Predict Positions route lands. - navigation.navigate(Routes.PREDICT.MARKET_LIST, { - entryPoint: PredictEventValues.ENTRY_POINT.HOMEPAGE_POSITIONS, - }); + navigation.navigate(Routes.PREDICT.POSITIONS); }, [navigation, onPositionsPress, portfolioAnalyticsProperties]); const handleAddFundsPress = useCallback(() => { @@ -156,7 +154,7 @@ const PredictPortfolioModule: React.FC = ({ const isWithdrawDisabled = availableBalance > 0 && !walletType; return ( - + ({ +jest.mock('../../../../../../../../locales/i18n', () => ({ strings: jest.fn((key: string, params?: Record) => { const mockStrings: Record = { 'predict.portfolio.available_amount': `${params?.amount} available`, diff --git a/app/components/UI/Predict/components/PredictPortfolio/PredictPortfolioSummary.tsx b/app/components/UI/Predict/views/PredictHome/components/PredictPortfolio/PredictPortfolioSummary.tsx similarity index 92% rename from app/components/UI/Predict/components/PredictPortfolio/PredictPortfolioSummary.tsx rename to app/components/UI/Predict/views/PredictHome/components/PredictPortfolio/PredictPortfolioSummary.tsx index b17479ddf74..35830c2434a 100644 --- a/app/components/UI/Predict/components/PredictPortfolio/PredictPortfolioSummary.tsx +++ b/app/components/UI/Predict/views/PredictHome/components/PredictPortfolio/PredictPortfolioSummary.tsx @@ -8,19 +8,19 @@ import { } from '@metamask/design-system-react-native'; import { useTailwind } from '@metamask/design-system-twrnc-preset'; import { typography } from '@metamask/design-tokens'; -import { strings } from '../../../../../../locales/i18n'; +import { strings } from '../../../../../../../../locales/i18n'; import SensitiveText, { SensitiveTextLength, -} from '../../../../../component-library/components/Texts/SensitiveText'; +} from '../../../../../../../component-library/components/Texts/SensitiveText'; import { TextColor as ComponentTextColor, TextVariant as ComponentTextVariant, -} from '../../../../../component-library/components/Texts/Text/Text.types'; -import { Skeleton } from '../../../../../component-library/components-temp/Skeleton'; +} from '../../../../../../../component-library/components/Texts/Text/Text.types'; +import { Skeleton } from '../../../../../../../component-library/components-temp/Skeleton'; import { formatPredictUnrealizedPnLStringParts, formatPrice, -} from '../../utils/format'; +} from '../../../../utils/format'; import { PREDICT_PORTFOLIO_TEST_IDS } from './PredictPortfolio.testIds'; const PRIMARY_SKELETON_HEIGHT = typography.sDisplayLG.lineHeight; diff --git a/app/components/UI/Predict/components/PredictPortfolio/index.ts b/app/components/UI/Predict/views/PredictHome/components/PredictPortfolio/index.ts similarity index 100% rename from app/components/UI/Predict/components/PredictPortfolio/index.ts rename to app/components/UI/Predict/views/PredictHome/components/PredictPortfolio/index.ts diff --git a/app/components/UI/Predict/views/PredictHome/components/PredictPortfolioModule/PredictPortfolioModule.tsx b/app/components/UI/Predict/views/PredictHome/components/PredictPortfolioModule/PredictPortfolioModule.tsx deleted file mode 100644 index 7efb4fe8475..00000000000 --- a/app/components/UI/Predict/views/PredictHome/components/PredictPortfolioModule/PredictPortfolioModule.tsx +++ /dev/null @@ -1,32 +0,0 @@ -import React from 'react'; -import { - Box, - Text, - TextColor, - TextVariant, -} from '@metamask/design-system-react-native'; -import { strings } from '../../../../../../../../locales/i18n'; -import { PredictHomeSelectorsIDs } from '../../../../Predict.testIds'; - -interface PredictPortfolioModuleProps { - testID?: string; -} - -/** - * Placeholder for the Predict home portfolio module (PRED-835). - * Replaced by the real module in a later ticket; the shell composes it as-is. - */ -const PredictPortfolioModule: React.FC = ({ - testID = PredictHomeSelectorsIDs.PORTFOLIO_MODULE, -}) => ( - - - {strings('predict.home.portfolio_module_placeholder')} - - -); - -export default PredictPortfolioModule; diff --git a/app/components/UI/Predict/views/PredictHome/components/PredictPortfolioModule/index.ts b/app/components/UI/Predict/views/PredictHome/components/PredictPortfolioModule/index.ts deleted file mode 100644 index 7c5dbb921ea..00000000000 --- a/app/components/UI/Predict/views/PredictHome/components/PredictPortfolioModule/index.ts +++ /dev/null @@ -1 +0,0 @@ -export { default } from './PredictPortfolioModule'; diff --git a/locales/languages/en.json b/locales/languages/en.json index 9373e6e6ff2..7f57b5a5911 100644 --- a/locales/languages/en.json +++ b/locales/languages/en.json @@ -2313,7 +2313,6 @@ "select_predict_account": "Select predict account", "search_account": "Search an account", "home": { - "portfolio_module_placeholder": "Portfolio module", "live_now_title": "Live now", "categories_placeholder": "Categories", "popular_today_placeholder": "Popular today", From b1bfc391f4f80184d8880b2a61572d40f65c1283 Mon Sep 17 00:00:00 2001 From: jiexi Date: Wed, 10 Jun 2026 08:36:24 -0700 Subject: [PATCH 13/15] feat: bump `@metamask/eip-5792-middleware` to ^3.0.4 (#26664) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ## **Description** Bump @metamask/eip-5792-middleware to ^3.0.4 [![Open in GitHub Codespaces](https://github.com/codespaces/badge.svg)](https://codespaces.new/MetaMask/metamask-extension/pull/40465?quickstart=1) ## **Changelog** CHANGELOG entry: null Please ping with any questions ## **Related issues** Fixes: https://consensys.slack.com/archives/C0318JSUBM3/p1780930822763839 ## **Manual testing steps** No user facing changes ## **Screenshots/Recordings** ### **Before** N/A ### **After** N/A ## **Pre-merge author checklist** - [x] I've followed [MetaMask Contributor Docs](https://github.com/MetaMask/contributor-docs) and [MetaMask Mobile Coding Standards](https://github.com/MetaMask/metamask-mobile/blob/main/.github/guidelines/CODING_GUIDELINES.md). - [x] I've completed the PR template to the best of my ability - [x] I've included tests if applicable - [x] I've documented my code using [JSDoc](https://jsdoc.app/) format if applicable - [x] I've applied the right labels on the PR (see [labeling guidelines](https://github.com/MetaMask/metamask-mobile/blob/main/.github/guidelines/LABELING_GUIDELINES.md)). Not required for external contributors. #### Performance checks (if applicable) - [x] I've tested on Android - Ideally on a mid-range device; emulator is acceptable - [x] I've tested with a power user scenario - Use these [power-user SRPs](https://consensyssoftware.atlassian.net/wiki/spaces/TL1/pages/edit-v2/401401446401?draftShareId=9d77e1e1-4bdc-4be1-9ebb-ccd916988d93) to import wallets with many accounts and tokens - [x] I've instrumented key operations with Sentry traces for production performance metrics - See [`trace()`](/app/util/trace.ts) for usage and [`addToken`](/app/components/Views/AddAsset/components/AddCustomToken/AddCustomToken.tsx#L274) for an example For performance guidelines and tooling, see the [Performance Guide](https://consensyssoftware.atlassian.net/wiki/spaces/TL1/pages/400085549067/Performance+Guide+for+Engineers). ## **Pre-merge reviewer checklist**GUIDELINES.md)). Not required for external contributors. ## **Pre-merge reviewer checklist** - [ ] I've manually tested the PR (e.g. pull and build branch, run the app, test code being changed). - [ ] I confirm that this PR addresses all acceptance criteria described in the ticket it closes and includes the necessary testing evidence such as recordings and or screenshots. --- > [!NOTE] > **Medium Risk** > Changes which accounts EIP-5792 batch send and capability RPCs can use for a given dapp origin; behavior shifts from all accounts to permission-scoped accounts, which can break or fix dapps depending on prior reliance on the old behavior. > > **Overview** > Upgrades **`@metamask/eip-5792-middleware`** from **^2.0.0** to **^3.0.4** and adapts the extension/mobile wiring to the package’s new hooks. > > **EIP-5792 account scoping:** `wallet_getCapabilities` and `wallet_sendCalls` no longer use a **`getAccounts`** callback that returned every address from **`AccountsController`**. They now supply **`getPermittedAccountsForOrigin`**, resolved via **`getPermittedAccounts(request.origin)`** in `createEip5792Middleware` and **`getPermittedAccounts(this.channelIdOrOrigin)`** on the **`processSendCalls`** options in **`BackgroundBridge`**. Capability queries and batched sends are tied to **origin-permitted** accounts instead of the full account list. > > **Small constant fix:** **`INTERNAL_ORIGINS`** is built with **`.filter(Boolean)`** so a missing **`MM_FOX_CODE`** env value does not leave a falsy entry in the array. > > Tests drop the obsolete **`getAccounts`** mock from the middleware factory smoke test. > > Reviewed by [Cursor Bugbot](https://cursor.com/bugbot) for commit a761a079a7eab2a2aaf81cde106ef1d1f16792e6. Bugbot is set up for automated code reviews on this repo. Configure [here](https://www.cursor.com/dashboard/bugbot). --------- Co-authored-by: Alex Donesky --- app/constants/transaction.ts | 2 +- app/core/BackgroundBridge/BackgroundBridge.js | 9 ++---- .../createEip5792Middleware.test.ts | 1 - .../RPCMethods/createEip5792Middleware.ts | 29 +++++++++++-------- package.json | 2 +- yarn.lock | 16 +++++----- 6 files changed, 29 insertions(+), 30 deletions(-) diff --git a/app/constants/transaction.ts b/app/constants/transaction.ts index b691c760456..9df808c1a7c 100644 --- a/app/constants/transaction.ts +++ b/app/constants/transaction.ts @@ -27,7 +27,7 @@ export const INTERNAL_ORIGINS = [ TransactionTypes.MMM, TransactionTypes.MMM_CARD, ORIGIN_METAMASK, -]; +].filter(Boolean); export enum EIP5792ErrorCode { UnsupportedNonOptionalCapability = 5700, diff --git a/app/core/BackgroundBridge/BackgroundBridge.js b/app/core/BackgroundBridge/BackgroundBridge.js index aecff35e386..17a8a38c6cf 100644 --- a/app/core/BackgroundBridge/BackgroundBridge.js +++ b/app/core/BackgroundBridge/BackgroundBridge.js @@ -870,13 +870,6 @@ export class BackgroundBridge extends EventEmitter { */ createEip5792Middleware() { return createEip5792Middleware({ - getAccounts: () => { - const { AccountsController } = Engine.context; - const addresses = AccountsController.listAccounts().map( - (acc) => acc.address, - ); - return Promise.resolve(addresses); - }, // EIP-5792 processSendCalls: processSendCalls.bind( null, @@ -915,6 +908,8 @@ export class BackgroundBridge extends EventEmitter { }), isAuxiliaryFundsSupported: (chainId) => ALLOWED_BRIDGE_CHAIN_IDS.includes(chainId), + getPermittedAccountsForOrigin: async () => + getPermittedAccounts(this.channelIdOrOrigin), }, Engine.controllerMessenger, ), diff --git a/app/core/RPCMethods/createEip5792Middleware.test.ts b/app/core/RPCMethods/createEip5792Middleware.test.ts index a6fc60c7c5f..f6164832889 100644 --- a/app/core/RPCMethods/createEip5792Middleware.test.ts +++ b/app/core/RPCMethods/createEip5792Middleware.test.ts @@ -3,7 +3,6 @@ import { createEip5792Middleware } from './createEip5792Middleware'; describe('createEip5792Middleware', () => { it('return instance of EIP-5792 Middleware', async () => { const middleware = createEip5792Middleware({ - getAccounts: jest.fn(), getCallsStatus: jest.fn(), getCapabilities: jest.fn(), processSendCalls: jest.fn(), diff --git a/app/core/RPCMethods/createEip5792Middleware.ts b/app/core/RPCMethods/createEip5792Middleware.ts index da2af5350b5..8c9ed09a8e3 100644 --- a/app/core/RPCMethods/createEip5792Middleware.ts +++ b/app/core/RPCMethods/createEip5792Middleware.ts @@ -11,31 +11,36 @@ import { createScaffoldMiddleware, } from '@metamask/json-rpc-engine'; import type { JsonRpcRequest } from '@metamask/utils'; +import { getPermittedAccounts } from '../Permissions'; + +type JsonRpcRequestWithOrigin = JsonRpcRequest & { origin: string }; export function createEip5792Middleware({ - getAccounts, getCallsStatus, getCapabilities, processSendCalls, }: { - getAccounts: (req: JsonRpcRequest) => Promise; getCallsStatus: GetCallsStatusHook; getCapabilities: GetCapabilitiesHook; processSendCalls: ProcessSendCallsHook; }) { return createScaffoldMiddleware({ - wallet_getCapabilities: createAsyncMiddleware(async (req, res) => - walletGetCapabilities(req, res, { - getAccounts, + wallet_getCapabilities: createAsyncMiddleware(async (req, res) => { + const request = req as unknown as JsonRpcRequestWithOrigin; + return walletGetCapabilities(request, res, { + getPermittedAccountsForOrigin: async () => + getPermittedAccounts(request.origin), getCapabilities, - }), - ), - wallet_sendCalls: createAsyncMiddleware(async (req, res) => - walletSendCalls(req, res, { - getAccounts, + }); + }), + wallet_sendCalls: createAsyncMiddleware(async (req, res) => { + const request = req as unknown as JsonRpcRequestWithOrigin; + return walletSendCalls(request, res, { + getPermittedAccountsForOrigin: async () => + getPermittedAccounts(request.origin), processSendCalls, - }), - ), + }); + }), wallet_getCallsStatus: createAsyncMiddleware(async (req, res) => walletGetCallsStatus(req, res, { getCallsStatus, diff --git a/package.json b/package.json index b2b280c333e..faa171efa5e 100644 --- a/package.json +++ b/package.json @@ -259,7 +259,7 @@ "@metamask/design-system-twrnc-preset": "^0.5.0", "@metamask/design-tokens": "^8.5.0", "@metamask/earn-controller": "^12.1.0", - "@metamask/eip-5792-middleware": "^2.0.0", + "@metamask/eip-5792-middleware": "^3.0.4", "@metamask/eip1193-permission-middleware": "^1.0.2", "@metamask/ens-resolver-snap": "^1.2.0", "@metamask/eth-hd-keyring": "^14.1.1", diff --git a/yarn.lock b/yarn.lock index 60a78a5d285..40a4abbc4a2 100644 --- a/yarn.lock +++ b/yarn.lock @@ -8465,17 +8465,17 @@ __metadata: languageName: node linkType: hard -"@metamask/eip-5792-middleware@npm:^2.0.0": - version: 2.0.0 - resolution: "@metamask/eip-5792-middleware@npm:2.0.0" +"@metamask/eip-5792-middleware@npm:^3.0.4": + version: 3.0.4 + resolution: "@metamask/eip-5792-middleware@npm:3.0.4" dependencies: - "@metamask/messenger": "npm:^0.3.0" + "@metamask/messenger": "npm:^1.2.0" "@metamask/superstruct": "npm:^3.1.0" - "@metamask/transaction-controller": "npm:^61.0.0" - "@metamask/utils": "npm:^11.8.1" + "@metamask/transaction-controller": "npm:^65.4.0" + "@metamask/utils": "npm:^11.9.0" lodash: "npm:^4.17.21" uuid: "npm:^8.3.2" - checksum: 10/7ccf426cbf9921b9c079da6877238fafa16353ff0b57027a3615ae9e07a39c1364c96c139673c7ed00dd00efe53e7467dfa9b3cc6c491ae89f68be0dfd047560 + checksum: 10/cac71793d2379399d964c02c1a8b5907aea177af938e8a18cbcf51a689a2d90736784bef751529a90c67e348382fc8b88116db1a601c41e5797f5b5166640562 languageName: node linkType: hard @@ -35261,7 +35261,7 @@ __metadata: "@metamask/design-system-twrnc-preset": "npm:^0.5.0" "@metamask/design-tokens": "npm:^8.5.0" "@metamask/earn-controller": "npm:^12.1.0" - "@metamask/eip-5792-middleware": "npm:^2.0.0" + "@metamask/eip-5792-middleware": "npm:^3.0.4" "@metamask/eip1193-permission-middleware": "npm:^1.0.2" "@metamask/ens-resolver-snap": "npm:^1.2.0" "@metamask/eslint-config-typescript": "npm:^13.0.0" From 8f251d4c872c551514fd58be772036c4454226eb Mon Sep 17 00:00:00 2001 From: cmd-ob Date: Wed, 10 Jun 2026 16:46:19 +0100 Subject: [PATCH 14/15] test(e2e): migrate base framework to use resolver and EncapsulatedElementType types (#31468) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ## **Description** Wires the dual-framework model through the E2E test framework layer so that Detox and Appium can share page objects without per-screen `encapsulated()` configs. **Matchers** — `getElementByID`, `getElementByText`, and `getElementByLabel` now return `EncapsulatedElementType` and route string selectors through `resolve()` from `Selector.ts`, which builds Detox + Playwright locator pairs at runtime. RegExp inputs still go directly to `element(by.*)` for Detox-only callers. **Gestures, Assertions, Utilities** — parameter types updated from `DetoxElement` to `EncapsulatedElementType`. Internal Detox-specific paths (`waitFor`, `getAttributes`) cast with `asDetoxElement` / `await` where needed. The redundant `DetoxElement | EncapsulatedElementType` union is collapsed to `EncapsulatedElementType` throughout. **Page objects** (~120 files) — getter return type annotations updated from `DetoxElement` to `EncapsulatedElementType`. Getter bodies are untouched. A small number of call sites that feed elements into raw Detox APIs retain explicit casts after `await`. No production app code is changed. All changes are confined to `tests/`. ## **Changelog** CHANGELOG entry: null ## **Related issues** Refs: https://consensyssoftware.atlassian.net/browse/MMQA-1927 Refs: https://consensyssoftware.atlassian.net/browse/MMQA-1928 ## **Manual testing steps** ```gherkin Feature: Dual-framework E2E selector layer Scenario: Existing Detox smoke tests continue to pass Given the branch is checked out When yarn jest tests/framework/ is run Then all framework unit tests pass with no regressions Scenario: TypeScript compilation is clean Given the branch is checked out When yarn lint:tsc is run Then zero TypeScript errors are reported ``` ## **Screenshots/Recordings** N/A — test infrastructure only, no UI changes. ### **Before** N/A ### **After** N/A ## **Pre-merge author checklist** - [x] I've followed [MetaMask Contributor Docs](https://github.com/MetaMask/contributor-docs) and [MetaMask Mobile Coding Standards](https://github.com/MetaMask/metamask-mobile/blob/main/.github/guidelines/CODING_GUIDELINES.md). - [x] I've completed the PR template to the best of my ability - [x] I've included tests if applicable - [x] I've documented my code using [JSDoc](https://jsdoc.app/) format if applicable - [x] I've applied the right labels on the PR (see [labeling guidelines](https://github.com/MetaMask/metamask-mobile/blob/main/.github/guidelines/LABELING_GUIDELINES.md)). Not required for external contributors. #### Performance checks (if applicable) - [x] I've tested on Android - Ideally on a mid-range device; emulator is acceptable - [x] I've tested with a power user scenario - Use these [power-user SRPs](https://consensyssoftware.atlassian.net/wiki/spaces/TL1/pages/edit-v2/401401446401?draftShareId=9d77e1e1-4bdc-4be1-9ebb-ccd916988d93) to import wallets with many accounts and tokens - [x] I've instrumented key operations with Sentry traces for production performance metrics - See [`trace()`](/app/util/trace.ts) for usage and [`addToken`](/app/components/Views/AddAsset/components/AddCustomToken/AddCustomToken.tsx#L274) for an example For performance guidelines and tooling, see the [Performance Guide](https://consensyssoftware.atlassian.net/wiki/spaces/TL1/pages/400085549067/Performance+Guide+for+Engineers). ## **Pre-merge reviewer checklist** - [ ] I've manually tested the PR (e.g. pull and build branch, run the app, test code being changed). - [ ] I confirm that this PR addresses all acceptance criteria described in the ticket it closes and includes the necessary testing evidence such as recordings and or screenshots. --- > [!NOTE] > **Medium Risk** > Broad typing and matcher behavior changes across the entire E2E stack could break Detox runs if resolve() or await/cast paths are wrong, though scope is limited to tests/. > > **Overview** > Standardizes the E2E layer on **`EncapsulatedElementType`** so Detox and Appium can share page objects without rewriting every screen. > > **Matchers** — `getElementByID`, `getElementByText`, and `getElementByLabel` are now synchronous and return `EncapsulatedElementType`. String selectors go through **`resolve()`** in `Selector.ts`, which pairs Detox locators with Playwright/Appium equivalents; **RegExp** matchers still call Detox `element(by.*)` directly. > > **Gestures, Assertions, Utilities** — APIs accept `EncapsulatedElementType` instead of `DetoxElement`. Text-based assertions and legacy helpers use **`asDetoxElement`** (or `await` + casts) before `waitFor` / `getAttributes`. > > **Page objects** — Getter return types move from `DetoxElement` to `EncapsulatedElementType` across the suite; a few spots that call raw Detox APIs keep explicit casts after `await`. > > **EncapsulatedElement** — `LocatorConfig.detox` factories are typed to return `EncapsulatedElementType`, with internal casts when materializing Detox elements. > > All changes are under `tests/`; no production app code. > > Reviewed by [Cursor Bugbot](https://cursor.com/bugbot) for commit 0100ae265f23df1f5ac07d76b186d39942075c08. Bugbot is set up for automated code reviews on this repo. Configure [here](https://www.cursor.com/dashboard/bugbot). --- .../e2e/pages/SampleFeatureView.ts | 45 +++--- tests/framework/Assertions.ts | 123 ++++++++++------- tests/framework/EncapsulatedElement.ts | 16 +-- tests/framework/Gestures.ts | 37 ++--- tests/framework/Matchers.ts | 45 +++--- tests/framework/Utilities.ts | 58 ++++---- tests/page-objects/AccountMenu/AccountMenu.ts | 23 ++-- tests/page-objects/Browser/AddBookmarkView.ts | 5 +- tests/page-objects/Browser/BrowserView.ts | 43 +++--- .../Browser/Confirmations/AlertSystem.ts | 21 +-- .../Confirmations/ConfirmationUITypes.ts | 5 +- .../Browser/Confirmations/FooterActions.ts | 5 +- .../Browser/Confirmations/RequestTypes.ts | 5 +- .../Browser/Confirmations/RowComponents.ts | 31 +++-- .../Browser/ConnectBottomSheet.ts | 15 +- .../Browser/ConnectedAccountsModal.ts | 39 +++--- .../Browser/ContractApprovalBottomSheet.ts | 19 +-- tests/page-objects/Browser/DownloadFile.ts | 10 +- .../Security/CameraWebsite.ts | 4 +- .../Browser/NetworkConnectMultiSelector.ts | 12 +- .../Browser/PermissionSummaryBottomSheet.ts | 13 +- .../Browser/SigningBottomSheet.ts | 9 +- tests/page-objects/Browser/SpamFilterModal.ts | 5 +- tests/page-objects/Browser/TestDApp.ts | 5 +- tests/page-objects/Browser/TestSnaps.ts | 26 ++-- tests/page-objects/Card/CardHomeView.ts | 17 +-- .../Common/ForgotPasswordModalView.ts | 29 ++-- tests/page-objects/CommonView.ts | 9 +- .../Confirmation/ConfirmationView.ts | 5 +- .../Confirmation/GasFeeTokenModal.ts | 21 +-- .../Confirmation/TokenApproveConfirmation.ts | 9 +- tests/page-objects/Earn/EarnLendingView.ts | 25 ++-- .../ErrorBoundaryView/ErrorBoundaryView.ts | 5 +- .../MultichainAccounts/AccountDetails.ts | 23 ++-- .../MultichainAccounts/AddressList.ts | 3 +- .../MultichainAccounts/DeleteAccount.ts | 5 +- .../MultichainAccounts/EditAccountName.ts | 7 +- .../MultichainAccounts/ExportCredentials.ts | 15 +- .../MultichainAccounts/PrivateKeyList.ts | 11 +- .../MultichainAccounts/ShareAddress.ts | 11 +- .../MultichainAccounts/SmartAccount.ts | 5 +- .../MultichainAccounts/WalletDetails.ts | 7 +- .../Network/NetworkAddedBottomSheet.ts | 7 +- .../Network/NetworkApprovalBottomSheet.ts | 9 +- .../Network/NetworkEducationModal.ts | 2 +- .../page-objects/Network/NetworkListModal.ts | 21 +-- .../Network/NetworkNonPemittedBottomSheet.ts | 17 +-- .../Onboarding/CreatePasswordView.ts | 6 +- .../ExperienceEnhancerBottomSheet.ts | 2 +- .../Onboarding/ImportWalletView.ts | 6 +- .../Onboarding/ManualBackupStep1View.ts | 5 +- .../Onboarding/MetaMetricsOptInView.ts | 6 +- .../Onboarding/OnboardingCarouselView.ts | 17 +-- .../Onboarding/OnboardingSheet.ts | 2 +- .../Onboarding/OnboardingSuccessView.ts | 2 +- .../page-objects/Onboarding/OnboardingView.ts | 2 +- .../Onboarding/ProtectYourWalletModal.ts | 7 +- .../Onboarding/ProtectYourWalletView.ts | 2 +- .../page-objects/Onboarding/WhatsNewModal.ts | 5 +- .../Perps/PerpsDepositProcessingView.ts | 9 +- tests/page-objects/Perps/PerpsDepositView.ts | 6 +- tests/page-objects/Perps/PerpsHomeView.ts | 5 +- .../Perps/PerpsMarketDetailsView.ts | 2 +- .../page-objects/Perps/PerpsMarketListView.ts | 3 +- tests/page-objects/Perps/PerpsOrderView.ts | 10 +- tests/page-objects/Perps/PerpsView.ts | 17 +-- tests/page-objects/Predict/PredictAddFunds.ts | 4 +- tests/page-objects/Predict/PredictBalance.ts | 12 +- .../Predict/PredictCashOutPage.ts | 6 +- .../page-objects/Predict/PredictClaimPage.ts | 6 +- .../Predict/PredictUnavailableView.ts | 7 +- tests/page-objects/Ramps/BuildQuoteView.ts | 52 +++---- tests/page-objects/Ramps/BuyGetStartedView.ts | 3 +- tests/page-objects/Ramps/KYCScreen.ts | 12 +- tests/page-objects/Ramps/OrderDetailsView.ts | 9 +- tests/page-objects/Ramps/QuotesView.ts | 13 +- .../Ramps/SelectPaymentMethodView.ts | 3 +- tests/page-objects/Ramps/SelectRegionView.ts | 5 +- .../page-objects/Ramps/SellGetStartedView.ts | 3 +- tests/page-objects/Ramps/TokenSelectScreen.ts | 3 +- tests/page-objects/Send/AddAddressModal.ts | 9 +- tests/page-objects/Send/RedesignedSendView.ts | 36 ++--- .../Send/TransactionConfirmView.ts | 31 +++-- .../Advanced/FiatOnTestnetsBottomSheet.ts | 3 +- tests/page-objects/Settings/AdvancedView.ts | 9 +- .../Settings/AesCryptoTestForm.ts | 51 +++---- .../Settings/BackupAndSyncView.ts | 7 +- .../Settings/Contacts/AddContactView.ts | 19 +-- .../Settings/Contacts/ContactsView.ts | 5 +- .../Contacts/DeleteContactBottomSheet.ts | 5 +- tests/page-objects/Settings/NetworksView.ts | 57 ++++---- .../SecurityAndPrivacy/AutoLockModal.ts | 3 +- .../SecurityAndPrivacy/ChangePasswordView.ts | 11 +- .../SecurityAndPrivacy/ClearPrivacyModal.ts | 7 +- .../SecurityAndPrivacy/DeleteWalletModal.ts | 9 +- .../RevealSecretRecoveryPhrase.ts | 19 +-- .../SecurityAndPrivacyView.ts | 33 ++--- .../SecurityAndPrivacy/SrpQuizModal.ts | 9 +- tests/page-objects/Settings/SettingsView.ts | 29 ++-- .../page-objects/Settings/SnapSettingsView.ts | 9 +- tests/page-objects/Stake/StakeConfirmView.ts | 3 +- tests/page-objects/Stake/StakeView.ts | 9 +- .../Transactions/ActivitiesView.ts | 45 +++--- .../Transactions/AssetWatchBottomSheet.ts | 7 +- .../Transactions/TransactionDetailsModal.ts | 16 +-- .../Transactions/UnifiedTransactionsView.ts | 3 +- .../predictionsActivityDetails.ts | 7 +- tests/page-objects/Trending/TrendingView.ts | 37 ++--- tests/page-objects/UI/FundActionMenu.ts | 9 +- .../importAccount/ImportAccountView.ts | 7 +- .../importAccount/SuccessImportAccountView.ts | 10 +- tests/page-objects/importSrp/ImportSrpView.ts | 11 +- tests/page-objects/swaps/Deeplink.ts | 5 +- tests/page-objects/swaps/OnBoarding.ts | 3 +- tests/page-objects/swaps/QuoteView.ts | 22 +-- tests/page-objects/swaps/SlippageModal.ts | 11 +- .../swaps/SwapTrendingTokensView.ts | 29 ++-- .../wallet/AccountActionsBottomSheet.ts | 13 +- .../wallet/AccountListBottomSheet.ts | 26 ++-- .../wallet/AddAccountBottomSheet.ts | 2 +- tests/page-objects/wallet/DefiPositionView.ts | 5 +- tests/page-objects/wallet/DefiView.ts | 6 +- .../wallet/EditAccountNameView.ts | 5 +- tests/page-objects/wallet/HomeSections.ts | 13 +- .../wallet/ImportNFTFlow/ImportNFTView.ts | 9 +- .../wallet/ImportTokenFlow/ConfirmAddAsset.ts | 11 +- .../ImportTokenFlow/ImportTokensView.ts | 22 +-- tests/page-objects/wallet/LoginView.ts | 4 +- .../MultiSrp/Common/SRPListComponent.ts | 3 +- tests/page-objects/wallet/NetworkManager.ts | 33 +++-- .../page-objects/wallet/NftDetectionModal.ts | 7 +- .../wallet/SelectNetworkBottomSheet.ts | 3 +- .../wallet/SendActionBottomSheet.ts | 15 +- tests/page-objects/wallet/ToastModal.ts | 6 +- tests/page-objects/wallet/TokenOverview.ts | 36 ++--- .../wallet/TokenSortBottomSheet.ts | 5 +- tests/page-objects/wallet/TokensView.ts | 5 +- .../wallet/WalletActionsBottomSheet.ts | 12 +- tests/page-objects/wallet/WalletView.ts | 130 +++++++++--------- tests/regression/wallet/carousel.spec.ts | 3 +- 140 files changed, 1139 insertions(+), 964 deletions(-) diff --git a/app/features/SampleFeature/e2e/pages/SampleFeatureView.ts b/app/features/SampleFeature/e2e/pages/SampleFeatureView.ts index 14c8b9927ac..e3ed5817a8d 100644 --- a/app/features/SampleFeature/e2e/pages/SampleFeatureView.ts +++ b/app/features/SampleFeature/e2e/pages/SampleFeatureView.ts @@ -2,72 +2,65 @@ import Matchers from '../../../../../tests/framework/Matchers'; import Gestures from '../../../../../tests/framework/Gestures'; import Assertions from '../../../../../tests/framework/Assertions'; +import { type EncapsulatedElementType } from '../../../../../tests/framework/EncapsulatedElement'; import { SampleFeatureSelectorsIDs, SampleFeatureSelectorsText, } from '../selectors/SampleFeature.selectors'; class SampleFeatureView { - get container(): Promise { + get container(): EncapsulatedElementType { return Matchers.getElementByID( SampleFeatureSelectorsIDs.SAMPLE_FEATURE_CONTAINER, - ) as Promise; + ); } - get title(): Promise { + get title(): EncapsulatedElementType { return Matchers.getElementByText( SampleFeatureSelectorsText.SAMPLE_FEATURE_TITLE, ); } - get description(): Promise { + get description(): EncapsulatedElementType { return Matchers.getElementByText( SampleFeatureSelectorsText.SAMPLE_FEATURE_DESCRIPTION, ); } - get counterTitle(): Promise { + get counterTitle(): EncapsulatedElementType { return Matchers.getElementByID( SampleFeatureSelectorsIDs.SAMPLE_COUNTER_PANE_TITLE, - ) as Promise; + ); } - get counterValue(): Promise { + get counterValue(): EncapsulatedElementType { return Matchers.getElementByID( SampleFeatureSelectorsIDs.SAMPLE_COUNTER_PANE_VALUE, - ) as Promise; + ); } - get incrementButton(): Promise { + get incrementButton(): EncapsulatedElementType { return Matchers.getElementByID( SampleFeatureSelectorsIDs.SAMPLE_COUNTER_PANE_INCREMENT_BUTTON, - ) as Promise; + ); } - get networkImage(): Promise { + get networkImage(): EncapsulatedElementType { // Assuming the network image has a testID - return Matchers.getElementByID( - 'network-avatar-image', - ) as Promise; + return Matchers.getElementByID('network-avatar-image'); } // Pet Name Elements - get petNameAddressInput(): Promise { - return Matchers.getElementByID( - 'pet-name-address-input', - ) as Promise; + get petNameAddressInput(): EncapsulatedElementType { + return Matchers.getElementByID('pet-name-address-input'); } - get petNameNameInput(): Promise { - return Matchers.getElementByID( - 'pet-name-name-input', - ) as Promise; + get petNameNameInput(): EncapsulatedElementType { + return Matchers.getElementByID('pet-name-name-input'); } - get addPetNameButton(): Promise { - return Matchers.getElementByID( - 'add-pet-name-button', - ) as Promise; + get addPetNameButton(): EncapsulatedElementType { + return Matchers.getElementByID('add-pet-name-button'); } async tapIncrementButton(): Promise { diff --git a/tests/framework/Assertions.ts b/tests/framework/Assertions.ts index 064d01b2ebc..e48a8aa4df2 100644 --- a/tests/framework/Assertions.ts +++ b/tests/framework/Assertions.ts @@ -2,6 +2,10 @@ import { waitFor } from 'detox'; import Utilities, { BASE_DEFAULTS } from './Utilities.ts'; import { AssertionOptions } from './types.ts'; import Matchers from './Matchers.ts'; +import { + asDetoxElement, + type EncapsulatedElementType, +} from './EncapsulatedElement.ts'; import { Json } from '@metamask/utils'; /** @@ -12,11 +16,12 @@ export default class Assertions { * Assert element is visible with auto-retry */ static async expectElementToBeVisible( - detoxElement: + elem: | DetoxElement | WebElement | DetoxMatcher - | IndexableNativeElement, + | IndexableNativeElement + | EncapsulatedElementType, options: AssertionOptions = {}, ): Promise { const { @@ -26,7 +31,9 @@ export default class Assertions { return Utilities.executeWithRetry( async () => { - const el = await detoxElement; + const el = (await elem) as Awaited< + DetoxElement | WebElement | DetoxMatcher | IndexableNativeElement + >; const isWebElement = Utilities.isWebElement(el); if (isWebElement) { // eslint-disable-next-line jest/valid-expect, @typescript-eslint/no-explicit-any @@ -48,11 +55,12 @@ export default class Assertions { * Assert element is not visible with auto-retry */ static async expectElementToNotBeVisible( - detoxElement: + elem: | DetoxElement | WebElement | DetoxMatcher - | IndexableNativeElement, + | IndexableNativeElement + | EncapsulatedElementType, options: AssertionOptions = {}, ): Promise { const { @@ -62,7 +70,9 @@ export default class Assertions { return Utilities.executeWithRetry( async () => { - const el = await detoxElement; + const el = (await elem) as Awaited< + DetoxElement | WebElement | DetoxMatcher | IndexableNativeElement + >; const isWebElement = Utilities.isWebElement(el); if (isWebElement) { // eslint-disable-next-line jest/valid-expect, @typescript-eslint/no-explicit-any @@ -82,7 +92,7 @@ export default class Assertions { * Assert element has specific text with auto-retry */ static async expectElementToHaveText( - detoxElement: DetoxElement, + elem: EncapsulatedElementType, text: string, options: AssertionOptions = {}, ): Promise { @@ -93,7 +103,7 @@ export default class Assertions { return Utilities.executeWithRetry( async () => { - const el = (await detoxElement) as Detox.IndexableNativeElement; + const el = (await elem) as Detox.IndexableNativeElement; await waitFor(el).toHaveText(text).withTimeout(100); }, { @@ -142,7 +152,7 @@ export default class Assertions { * Assert element does not have specific text with auto-retry */ static async expectElementToNotHaveText( - detoxElement: DetoxElement, + elem: EncapsulatedElementType, text: string, options: AssertionOptions = {}, ): Promise { @@ -153,7 +163,7 @@ export default class Assertions { return Utilities.executeWithRetry( async () => { - const el = (await detoxElement) as Detox.IndexableNativeElement; + const el = (await elem) as Detox.IndexableNativeElement; await waitFor(el).not.toHaveText(text).withTimeout(100); }, { @@ -167,7 +177,7 @@ export default class Assertions { * Assert element has specific label with auto-retry */ static async expectElementToHaveLabel( - detoxElement: DetoxElement, + elem: EncapsulatedElementType, label: string, options: AssertionOptions = {}, ): Promise { @@ -178,7 +188,7 @@ export default class Assertions { return Utilities.executeWithRetry( async () => { - const el = (await detoxElement) as Detox.IndexableNativeElement; + const el = (await elem) as Detox.IndexableNativeElement; await waitFor(el).toHaveLabel(label).withTimeout(100); }, { @@ -201,12 +211,17 @@ export default class Assertions { return Utilities.executeWithRetry( async () => { const textElement = allowDuplicates - ? (await Matchers.getElementByText(text)).atIndex(0) - : await Matchers.getElementByText(text); + ? Matchers.getElementByText(text, 0) + : Matchers.getElementByText(text); + const el = await asDetoxElement(textElement); if (device.getPlatform() === 'ios') { - await waitFor(textElement).toExist().withTimeout(100); + await waitFor(el as Detox.NativeElement) + .toExist() + .withTimeout(100); } else { - await waitFor(textElement).toBeVisible().withTimeout(100); + await waitFor(el as Detox.NativeElement) + .toBeVisible() + .withTimeout(100); } }, { @@ -228,11 +243,15 @@ export default class Assertions { const { timeout = BASE_DEFAULTS.timeout } = options; return Utilities.executeWithRetry( async () => { - const textElement = await Matchers.getElementByText(text); + const el = await asDetoxElement(Matchers.getElementByText(text)); if (device.getPlatform() === 'ios') { - await waitFor(textElement).not.toExist().withTimeout(100); + await waitFor(el as Detox.NativeElement) + .not.toExist() + .withTimeout(100); } else { - await waitFor(textElement).not.toBeVisible().withTimeout(100); + await waitFor(el as Detox.NativeElement) + .not.toBeVisible() + .withTimeout(100); } }, { @@ -246,7 +265,7 @@ export default class Assertions { * Assert element is enabled with auto-retry */ static async expectToggleToBeOn( - detoxElement: DetoxElement, + elem: EncapsulatedElementType, options: AssertionOptions = {}, ): Promise { const { @@ -258,7 +277,7 @@ export default class Assertions { async () => { try { const el = (await Utilities.waitForReadyState( - detoxElement, + elem, )) as Detox.IndexableNativeElement; // eslint-disable-next-line jest/valid-expect, @typescript-eslint/no-explicit-any await (expect(el) as any).toHaveToggleValue(true); @@ -284,7 +303,7 @@ export default class Assertions { * Assert element is disabled with auto-retry */ static async expectToggleToBeOff( - detoxElement: DetoxElement, + elem: EncapsulatedElementType, options: AssertionOptions = {}, ): Promise { const { @@ -296,7 +315,7 @@ export default class Assertions { async () => { try { const el = (await Utilities.waitForReadyState( - detoxElement, + elem, )) as Detox.IndexableNativeElement; // eslint-disable-next-line jest/valid-expect, @typescript-eslint/no-explicit-any await (expect(el) as any).toHaveToggleValue(false); @@ -326,7 +345,7 @@ export default class Assertions { throw new Error('Both actual and expected text must be provided'); } - return expect(actualText).toBe(expectedText); + expect(actualText).toBe(expectedText); } catch (error) { if (actualText !== expectedText) { throw new Error( @@ -541,19 +560,21 @@ export default class Assertions { * @deprecated Use expectElementToBeVisible() instead for better error handling and retry mechanisms */ static async checkIfVisible( - detoxElement: DetoxElement, + elem: EncapsulatedElementType, timeout = 15000, ): Promise { - return this.expectElementToBeVisible(detoxElement, { timeout }); + return this.expectElementToBeVisible(elem, { timeout }); } /** * Legacy method: Check if a web element exists * @deprecated Use expectElementToBeVisible() instead for better error handling and retry mechanisms */ - static async webViewElementExists(detoxElement: DetoxElement): Promise { + static async webViewElementExists( + elem: EncapsulatedElementType, + ): Promise { // For web elements, just use the basic expect assertion - const el = (await detoxElement) as Detox.IndexableNativeElement; + const el = (await elem) as Detox.IndexableNativeElement; // Use Detox's expect which has toExist method // Use Detox's expect syntax for element existence // eslint-disable-next-line @typescript-eslint/no-explicit-any, jest/valid-expect @@ -565,10 +586,10 @@ export default class Assertions { * @deprecated Use expectElementToNotBeVisible() instead for better error handling and retry mechanisms */ static async checkIfNotVisible( - detoxElement: DetoxElement, + elem: EncapsulatedElementType, timeout = 15000, ): Promise { - return this.expectElementToNotBeVisible(detoxElement as DetoxElement, { + return this.expectElementToNotBeVisible(elem as DetoxElement, { timeout, }); } @@ -578,11 +599,11 @@ export default class Assertions { * @deprecated Use expectElementToHaveText() instead for better error handling and retry mechanisms */ static async checkIfElementToHaveText( - detoxElement: DetoxElement, + elem: EncapsulatedElementType, text: string, timeout = 15000, ): Promise { - return this.expectElementToHaveText(detoxElement as DetoxElement, text, { + return this.expectElementToHaveText(elem as DetoxElement, text, { timeout, }); } @@ -592,11 +613,11 @@ export default class Assertions { * @deprecated Use expectElementToHaveLabel() instead for better error handling and retry mechanisms */ static async checkIfElementHasLabel( - detoxElement: DetoxElement, + elem: EncapsulatedElementType, label: string, timeout = 15000, ): Promise { - return this.expectElementToHaveLabel(detoxElement, label, { timeout }); + return this.expectElementToHaveLabel(elem, label, { timeout }); } /** @@ -620,8 +641,10 @@ export default class Assertions { ): Promise { return Utilities.executeWithRetry( async () => { - const textElement = await Matchers.getElementByText(text); - await waitFor(textElement).not.toBeVisible().withTimeout(100); + const el = await asDetoxElement(Matchers.getElementByText(text)); + await waitFor(el as Detox.NativeElement) + .not.toBeVisible() + .withTimeout(100); }, { timeout, @@ -635,13 +658,13 @@ export default class Assertions { * @deprecated Use expectElementToNotHaveText() or custom assertion instead for better error handling and retry mechanisms */ static async checkIfElementNotToHaveText( - detoxElement: DetoxElement, + elem: EncapsulatedElementType, text: string, timeout = 15000, ): Promise { return Utilities.executeWithRetry( async () => { - const el = (await detoxElement) as Detox.IndexableNativeElement; + const el = (await elem) as Detox.IndexableNativeElement; await waitFor(el).not.toHaveText(text).withTimeout(100); }, { @@ -656,13 +679,13 @@ export default class Assertions { * @deprecated Use expectElementToNotBeVisible() or custom assertion instead for better error handling and retry mechanisms */ static async checkIfElementDoesNotHaveLabel( - detoxElement: DetoxElement, + elem: EncapsulatedElementType, label: string, timeout = 15000, ): Promise { return Utilities.executeWithRetry( async () => { - const el = (await detoxElement) as Detox.IndexableNativeElement; + const el = (await elem) as Detox.IndexableNativeElement; await waitFor(el).not.toHaveLabel(label).withTimeout(100); }, { @@ -676,8 +699,8 @@ export default class Assertions { * Legacy method: Check if toggle is in "on" state * @deprecated Use expectToggleToBeOn() instead for better error handling and retry mechanisms */ - static async checkIfToggleIsOn(detoxElement: DetoxElement): Promise { - const el = (await detoxElement) as Detox.IndexableNativeElement; + static async checkIfToggleIsOn(elem: EncapsulatedElementType): Promise { + const el = (await elem) as Detox.IndexableNativeElement; // Use Detox's expect syntax for toggle values // eslint-disable-next-line @typescript-eslint/no-explicit-any, jest/valid-expect await (expect(el) as any).toHaveToggleValue(true); @@ -687,8 +710,10 @@ export default class Assertions { * Legacy method: Check if toggle is in "off" state * @deprecated Use expectToggleToBeOff() instead for better error handling and retry mechanisms */ - static async checkIfToggleIsOff(detoxElement: DetoxElement): Promise { - const el = (await detoxElement) as Detox.IndexableNativeElement; + static async checkIfToggleIsOff( + elem: EncapsulatedElementType, + ): Promise { + const el = (await elem) as Detox.IndexableNativeElement; // Use Detox's expect syntax for toggle values // eslint-disable-next-line @typescript-eslint/no-explicit-any, jest/valid-expect await (expect(el) as any).toHaveToggleValue(false); @@ -698,8 +723,8 @@ export default class Assertions { * Legacy method: Check if element is enabled * @deprecated Use Utilities.waitForElementToBeEnabled() instead for better retry handling */ - static async checkIfEnabled(detoxElement: DetoxElement): Promise { - const el = (await detoxElement) as Detox.IndexableNativeElement; + static async checkIfEnabled(elem: EncapsulatedElementType): Promise { + const el = (await elem) as Detox.IndexableNativeElement; const attributes = await el.getAttributes(); return 'enabled' in attributes ? !!attributes.enabled : false; } @@ -708,8 +733,10 @@ export default class Assertions { * Legacy method: Check if element is disabled * @deprecated Use Utilities.waitForElementToBeEnabled() with negated logic instead */ - static async checkIfDisabled(detoxElement: DetoxElement): Promise { - const el = (await detoxElement) as Detox.IndexableNativeElement; + static async checkIfDisabled( + elem: EncapsulatedElementType, + ): Promise { + const el = (await elem) as Detox.IndexableNativeElement; const attributes = await el.getAttributes(); return 'enabled' in attributes ? !attributes.enabled : true; } diff --git a/tests/framework/EncapsulatedElement.ts b/tests/framework/EncapsulatedElement.ts index 24bcc54b1b8..3bdf3f82a37 100644 --- a/tests/framework/EncapsulatedElement.ts +++ b/tests/framework/EncapsulatedElement.ts @@ -44,7 +44,7 @@ export interface PlatformLocator { * } */ export interface LocatorConfig { - detox?: () => DetoxElement; + detox?: () => EncapsulatedElementType; appium?: | (() => Promise) | { @@ -120,7 +120,7 @@ export class EncapsulatedElement { } // Execute the function to get the DetoxElement - return config.detox(); + return config.detox() as unknown as DetoxElement; } /** @@ -137,7 +137,7 @@ export class EncapsulatedElement { // If appium is a function, use it as a generic locator if (typeof config.appium === 'function') { - return config.appium(); + return config.appium() as Promise; } // Otherwise, it's a platform-specific configuration @@ -151,7 +151,7 @@ export class EncapsulatedElement { } // Execute the platform-specific function to get the Promise - return platformLocator(); + return platformLocator() as Promise; } } @@ -175,9 +175,9 @@ export function encapsulated(config: LocatorConfig): EncapsulatedElementType { * @returns PlaywrightElement */ export async function asPlaywrightElement( - element: EncapsulatedElementType, + elem: EncapsulatedElementType, ): Promise { - return (await element) as PlaywrightElement; + return (await elem) as PlaywrightElement; } /** @@ -192,6 +192,6 @@ export async function asPlaywrightElement( * * @returns DetoxElement */ -export function asDetoxElement(element: EncapsulatedElementType): DetoxElement { - return element as DetoxElement; +export function asDetoxElement(elem: EncapsulatedElementType): DetoxElement { + return elem as DetoxElement; } diff --git a/tests/framework/Gestures.ts b/tests/framework/Gestures.ts index 01f61a078d6..03dbc9c710a 100644 --- a/tests/framework/Gestures.ts +++ b/tests/framework/Gestures.ts @@ -11,6 +11,7 @@ import { } from './types.ts'; import { createLogger } from './logger.ts'; import { sleep } from '../../app/util/testUtils'; +import { type EncapsulatedElementType } from './EncapsulatedElement.ts'; const logger = createLogger({ name: 'Gestures' }); @@ -24,7 +25,7 @@ export default class Gestures { * @param options - Options for the tap action */ private static tapWithChecks = async ( - elem: DetoxElement | WebElement, + elem: DetoxElement | WebElement | EncapsulatedElementType, options: { checkStability?: boolean; checkVisibility?: boolean; @@ -83,7 +84,7 @@ export default class Gestures { * @throws Will retry the operation if it fails, with retry logic handled by executeWith */ static async tap( - elem: DetoxElement | WebElement, + elem: DetoxElement | WebElement | EncapsulatedElementType, options: TapOptions = {}, ): Promise { const { @@ -120,7 +121,7 @@ export default class Gestures { * @throws Will retry the operation if it fails, with retry logic handled by executeWith */ static async waitAndTap( - elem: DetoxElement | WebElement, + elem: DetoxElement | WebElement | EncapsulatedElementType, options: TapOptions = {}, ): Promise { const { @@ -156,7 +157,7 @@ export default class Gestures { * @throws Will retry the operation if it fails, with retry logic handled by executeWithRetry */ static async tapAtIndex( - elem: DetoxElement, + elem: DetoxElement | EncapsulatedElementType, index: number, options: TapOptions = {}, ): Promise { @@ -203,7 +204,7 @@ export default class Gestures { * @throws Will retry the operation if it fails, with retry logic handled by executeWithRetry */ static async tapAtPoint( - elem: DetoxElement, + elem: DetoxElement | EncapsulatedElementType, point: { x: number; y: number }, options: TapOptions = {}, ): Promise { @@ -240,7 +241,7 @@ export default class Gestures { * @throws Will retry the operation if it fails, with retry logic handled by executeWithRetry */ static async dblTap( - elem: DetoxElement, + elem: DetoxElement | EncapsulatedElementType, options: TapOptions = {}, ): Promise { const { @@ -279,7 +280,7 @@ export default class Gestures { * @throws Will retry the operation if it fails, with retry logic handled by executeWithRetry */ static async longPress( - elem: DetoxElement, + elem: DetoxElement | EncapsulatedElementType, options: LongPressOptions = {}, ): Promise { const { @@ -319,7 +320,7 @@ export default class Gestures { * @throws Will retry the operation if it fails, with retry logic handled by executeWith */ static async typeText( - elem: DetoxElement, + elem: DetoxElement | EncapsulatedElementType, text: string, options: TypeTextOptions = {}, ): Promise { @@ -412,7 +413,7 @@ export default class Gestures { * @throws Will retry the operation if it fails, with retry logic handled by executeWithRetry */ static async replaceText( - elem: DetoxElement, + elem: DetoxElement | EncapsulatedElementType, text: string, options: GestureOptions = {}, ): Promise { @@ -453,7 +454,7 @@ export default class Gestures { * @throws Will retry the operation if it fails, with retry logic handled by executeWith */ static async swipe( - elem: DetoxElement, + elem: DetoxElement | EncapsulatedElementType, direction: 'up' | 'down' | 'left' | 'right', options: SwipeOptions = {}, ): Promise { @@ -501,7 +502,7 @@ export default class Gestures { * @throws Will retry the operation if it fails, with retry logic handled by executeWith */ static async scrollToElement( - targetElement: DetoxElement, + targetElement: DetoxElement | EncapsulatedElementType, scrollableContainer: Promise, options: ScrollOptions = {}, ): Promise { @@ -576,7 +577,7 @@ export default class Gestures { * @deprecated Use longPress() instead for better error handling and retry mechanisms */ static async tapAndLongPress( - elem: DetoxElement, + elem: DetoxElement | EncapsulatedElementType, timeout = 2000, ): Promise { return this.longPress(elem, { duration: timeout }); @@ -608,7 +609,9 @@ export default class Gestures { * Legacy method: Double tap an element * @deprecated Use dblTap() instead for better error handling and retry mechanisms - we should replace the function name when we have migrated all usages */ - static async doubleTap(elem: DetoxElement): Promise { + static async doubleTap( + elem: DetoxElement | EncapsulatedElementType, + ): Promise { return Utilities.executeWithRetry( async () => { const el = (await elem) as Detox.IndexableNativeElement; @@ -625,7 +628,7 @@ export default class Gestures { * @deprecated Use typeText() with clearFirst option or the replaceText() from Gestures.ts instead for better error handling and retry mechanisms */ static async clearField( - elem: DetoxElement, + elem: DetoxElement | EncapsulatedElementType, options: GestureOptions = {}, ): Promise { const { @@ -663,7 +666,7 @@ export default class Gestures { * @deprecated Use typeText() with hideKeyboard option instead for better error handling and retry mechanisms */ static async typeTextAndHideKeyboard( - elem: DetoxElement, + elem: DetoxElement | EncapsulatedElementType, text: string, ): Promise { return this.typeText(elem, text, { @@ -677,7 +680,7 @@ export default class Gestures { * @deprecated Use typeText() with hideKeyboard: false option instead for better error handling and retry mechanisms */ static async typeTextWithoutKeyboard( - elem: DetoxElement, + elem: DetoxElement | EncapsulatedElementType, text: string, ): Promise { return this.typeText(elem, text, { @@ -691,7 +694,7 @@ export default class Gestures { * @deprecated Use replaceText() instead for better error handling and retry mechanisms */ static async replaceTextInField( - elem: DetoxElement, + elem: DetoxElement | EncapsulatedElementType, text: string, timeout = 10000, ): Promise { diff --git a/tests/framework/Matchers.ts b/tests/framework/Matchers.ts index 42c08715465..649b6f6f34d 100644 --- a/tests/framework/Matchers.ts +++ b/tests/framework/Matchers.ts @@ -1,33 +1,38 @@ import { web, system } from 'detox'; +import { type EncapsulatedElementType } from './EncapsulatedElement.ts'; +import { resolve } from './Selector.ts'; /** * Utility class for matching (locating) UI elements */ export default class Matchers { /** - * Get element by ID with optional index + * Get element by ID with optional index. */ - static async getElementByID( + static getElementByID( elementId: string | RegExp, index?: number, - ): Promise { - const el = element(by.id(elementId)); - if (index !== undefined) { - return el.atIndex(index) as Detox.IndexableNativeElement; + ): EncapsulatedElementType { + if (typeof elementId === 'string') { + return resolve({ testID: elementId, index }); } - return el as Detox.IndexableNativeElement; + const el = element(by.id(elementId)); + return (index !== undefined + ? el.atIndex(index) + : el) as unknown as DetoxElement; } /** - * Get element by text with optional index + * Get element by text with optional index. */ - static async getElementByText( + static getElementByText( text: string | RegExp, index = 0, - ): Promise { - return element(by.text(text)).atIndex( - index, - ) as Detox.IndexableNativeElement; + ): EncapsulatedElementType { + if (typeof text === 'string') { + return resolve({ text, index }); + } + return element(by.text(text)).atIndex(index) as unknown as DetoxElement; } /** @@ -39,7 +44,9 @@ export default class Matchers { ): Promise { const escaped = containsText.replace(/[.*+?^${}()|[\]\\]/g, '\\$&'); const pattern = new RegExp(escaped, 'i'); - return this.getElementByText(pattern, index); + return element(by.text(pattern)).atIndex( + index, + ) as Detox.IndexableNativeElement; } /** @@ -60,13 +67,11 @@ export default class Matchers { /** * Get element by label (accessibility label on iOS, content description on Android) */ - static async getElementByLabel( + static getElementByLabel( label: string, - index = 0, - ): Promise { - return element(by.label(label)).atIndex( - index, - ) as Detox.IndexableNativeElement; + index?: number, + ): EncapsulatedElementType { + return resolve({ label, index }); } /** diff --git a/tests/framework/Utilities.ts b/tests/framework/Utilities.ts index 873b2622746..7bf12c8923f 100644 --- a/tests/framework/Utilities.ts +++ b/tests/framework/Utilities.ts @@ -1,6 +1,7 @@ import { waitFor } from 'detox'; import { blacklistURLs } from '../resources/blacklistURLs.json'; import { RetryOptions, StabilityOptions } from './types.ts'; +import { type EncapsulatedElementType } from './EncapsulatedElement.ts'; import { createLogger } from './logger.ts'; // eslint-disable-next-line import-x/no-nodejs-modules import { setTimeout as asyncSetTimeout } from 'node:timers/promises'; @@ -39,8 +40,10 @@ export default class Utilities { /** * Check if element is enabled (non-retry version) */ - static async checkElementEnabled(detoxElement: DetoxElement): Promise { - const el = (await detoxElement) as Detox.IndexableNativeElement; + static async checkElementEnabled( + elem: EncapsulatedElementType, + ): Promise { + const el = (await elem) as Detox.IndexableNativeElement; const attributes = await el.getAttributes(); if (!('enabled' in attributes) || !attributes.enabled) { throw new Error( @@ -57,8 +60,10 @@ export default class Utilities { } } - static async checkElementDisabled(detoxElement: DetoxElement): Promise { - const el = (await detoxElement) as Detox.IndexableNativeElement; + static async checkElementDisabled( + elem: EncapsulatedElementType, + ): Promise { + const el = (await elem) as Detox.IndexableNativeElement; const attributes = await el.getAttributes(); if (!('enabled' in attributes) || attributes.enabled) { throw new Error('🚫 Element is enabled, but should be disabled.'); @@ -69,11 +74,11 @@ export default class Utilities { * Wait for element to be enabled with retry mechanism */ static async waitForElementToBeEnabled( - detoxElement: DetoxElement, + elem: EncapsulatedElementType, timeout = 3500, interval = 100, ): Promise { - return this.executeWithRetry(() => this.checkElementEnabled(detoxElement), { + return this.executeWithRetry(() => this.checkElementEnabled(elem), { timeout, interval, description: 'Element to be enabled', @@ -85,10 +90,10 @@ export default class Utilities { * Android-specific check for element obscuration */ static async checkElementNotObscured( - detoxElement: DetoxElement, + elem: EncapsulatedElementType, ): Promise { try { - const el = (await detoxElement) as Detox.IndexableNativeElement; + const el = (await elem) as Detox.IndexableNativeElement; const attributes = await el.getAttributes(); // Check if element has proper frame/bounds @@ -138,7 +143,7 @@ export default class Utilities { * Check if element is stable (non-retry version) */ static async checkElementStable( - detoxElement: DetoxElement, + elem: EncapsulatedElementType, options: StabilityOptions = {}, ): Promise { const { timeout = 2000, interval = 200, stableCount = 3 } = options; @@ -165,7 +170,7 @@ export default class Utilities { }; while (Date.now() - start < timeout) { - const el = (await detoxElement) as Detox.IndexableNativeElement; + const el = (await elem) as Detox.IndexableNativeElement; const position = await getPosition(el); if (!position) { @@ -199,24 +204,21 @@ export default class Utilities { * Waits for an element to become stable (not moving) by checking its position multiple times. */ static async waitForElementToStopMoving( - detoxElement: DetoxElement, + elem: EncapsulatedElementType, options: StabilityOptions = {}, ): Promise { const { timeout = 5000 } = options; - return this.executeWithRetry( - () => this.checkElementStable(detoxElement, options), - { - timeout, - description: 'Element stability', - }, - ); + return this.executeWithRetry(() => this.checkElementStable(elem, options), { + timeout, + description: 'Element stability', + }); } /** * Check element ready state (non-retry version) */ static async checkElementReadyState( - detoxElement: DetoxElement, + elem: EncapsulatedElementType, options: { timeout?: number; checkStability?: boolean; @@ -231,7 +233,7 @@ export default class Utilities { checkEnabled = true, } = options; - const el = (await detoxElement) as Detox.IndexableNativeElement; + const el = (await elem) as Detox.IndexableNativeElement; /** * IMPORTANT: Default timeout behavior * @@ -279,7 +281,7 @@ export default class Utilities { * Wait for element to be in a ready state (visible, enabled, stable) */ static async waitForReadyState( - detoxElement: DetoxElement, + elem: EncapsulatedElementType, options: { timeout?: number; checkStability?: boolean; @@ -290,7 +292,7 @@ export default class Utilities { const { timeout = TEST_CONFIG_DEFAULTS.timeout, elemDescription } = options; return this.executeWithRetry( - () => this.checkElementReadyState(detoxElement, options), + () => this.checkElementReadyState(elem, options), { timeout, description: 'Element ready state check', @@ -303,10 +305,10 @@ export default class Utilities { * Wait for element to be visible and throw on failure */ static async waitForElementToBeVisible( - detoxElement: DetoxElement | DetoxMatcher, + elem: DetoxMatcher | EncapsulatedElementType, timeout: number = 2000, ): Promise { - const el = (await detoxElement) as Detox.IndexableNativeElement; + const el = (await elem) as Detox.IndexableNativeElement; const isWebElement = this.isWebElement(el); if (isWebElement) { @@ -323,10 +325,10 @@ export default class Utilities { * Wait for element to be not visible and throw on failure */ static async waitForElementToDisappear( - detoxElement: DetoxElement | DetoxMatcher, + elem: DetoxMatcher | EncapsulatedElementType, timeout: number = 2000, ): Promise { - const el = (await detoxElement) as Detox.IndexableNativeElement; + const el = (await elem) as Detox.IndexableNativeElement; const isWebElement = this.isWebElement(el); if (isWebElement) { // eslint-disable-next-line jest/valid-expect, @typescript-eslint/no-explicit-any @@ -343,11 +345,11 @@ export default class Utilities { * Returns true if element is visible, false if not visible or doesn't exist */ static async isElementVisible( - detoxElement: DetoxElement | DetoxMatcher, + elem: DetoxMatcher | EncapsulatedElementType, timeout: number = 2000, ): Promise { try { - await this.waitForElementToBeVisible(detoxElement, timeout); + await this.waitForElementToBeVisible(elem, timeout); return true; } catch { return false; diff --git a/tests/page-objects/AccountMenu/AccountMenu.ts b/tests/page-objects/AccountMenu/AccountMenu.ts index 8d98dd9d389..f58989d915e 100644 --- a/tests/page-objects/AccountMenu/AccountMenu.ts +++ b/tests/page-objects/AccountMenu/AccountMenu.ts @@ -1,47 +1,48 @@ import { AccountsMenuSelectorsIDs } from '../../../app/components/Views/AccountsMenu/AccountsMenu.testIds'; import Matchers from '../../../tests/framework/Matchers'; import Gestures from '../../../tests/framework/Gestures'; +import { EncapsulatedElementType } from '../../framework'; class AccountMenu { - get container(): DetoxElement { + get container(): EncapsulatedElementType { return Matchers.getElementByID( AccountsMenuSelectorsIDs.ACCOUNTS_MENU_SCROLL_ID, ); } - get backButton(): DetoxElement { + get backButton(): EncapsulatedElementType { return Matchers.getElementByID(AccountsMenuSelectorsIDs.BACK_BUTTON); } - get settingsButton(): DetoxElement { + get settingsButton(): EncapsulatedElementType { return Matchers.getElementByID(AccountsMenuSelectorsIDs.SETTINGS); } - get contactsButton(): DetoxElement { + get contactsButton(): EncapsulatedElementType { return Matchers.getElementByID(AccountsMenuSelectorsIDs.CONTACTS); } - get manageCardButton(): DetoxElement { + get manageCardButton(): EncapsulatedElementType { return Matchers.getElementByID(AccountsMenuSelectorsIDs.MANAGE_CARD); } - get permissionsButton(): DetoxElement { + get permissionsButton(): EncapsulatedElementType { return Matchers.getElementByID(AccountsMenuSelectorsIDs.PERMISSIONS); } - get aboutMetaMaskButton(): DetoxElement { + get aboutMetaMaskButton(): EncapsulatedElementType { return Matchers.getElementByID(AccountsMenuSelectorsIDs.ABOUT_METAMASK); } - get requestFeatureButton(): DetoxElement { + get requestFeatureButton(): EncapsulatedElementType { return Matchers.getElementByID(AccountsMenuSelectorsIDs.REQUEST_FEATURE); } - get supportButton(): DetoxElement { + get supportButton(): EncapsulatedElementType { return Matchers.getElementByID(AccountsMenuSelectorsIDs.SUPPORT); } - get lockButton(): DetoxElement { + get lockButton(): EncapsulatedElementType { return Matchers.getElementByID(AccountsMenuSelectorsIDs.LOCK); } @@ -93,7 +94,7 @@ class AccountMenu { }); } - get notificationsButton(): DetoxElement { + get notificationsButton(): EncapsulatedElementType { return Matchers.getElementByID( AccountsMenuSelectorsIDs.NOTIFICATIONS_BUTTON, ); diff --git a/tests/page-objects/Browser/AddBookmarkView.ts b/tests/page-objects/Browser/AddBookmarkView.ts index e0aafe4e7e0..43356676495 100644 --- a/tests/page-objects/Browser/AddBookmarkView.ts +++ b/tests/page-objects/Browser/AddBookmarkView.ts @@ -1,13 +1,14 @@ import { AddBookmarkViewSelectorsIDs } from '../../../app/components/Views/AddBookmark/AddBookmarkView.testIds'; import Gestures from '../../framework/Gestures'; import Matchers from '../../framework/Matchers'; +import { EncapsulatedElementType } from '../../framework'; class AddFavoritesView { - get container(): DetoxElement { + get container(): EncapsulatedElementType { return Matchers.getElementByID(AddBookmarkViewSelectorsIDs.CONTAINER); } - get addBookmarkButton(): DetoxElement { + get addBookmarkButton(): EncapsulatedElementType { return device.getPlatform() === 'ios' ? Matchers.getElementByID(AddBookmarkViewSelectorsIDs.CONFIRM_BUTTON) : Matchers.getElementByLabel(AddBookmarkViewSelectorsIDs.CONFIRM_BUTTON); diff --git a/tests/page-objects/Browser/BrowserView.ts b/tests/page-objects/Browser/BrowserView.ts index 0946ce6178b..cc45482e9e5 100644 --- a/tests/page-objects/Browser/BrowserView.ts +++ b/tests/page-objects/Browser/BrowserView.ts @@ -11,6 +11,7 @@ import { getTestDappLocalUrl, getDappUrl, } from '../../framework/fixtures/FixtureUtils'; +import { EncapsulatedElementType } from '../../framework/EncapsulatedElement'; import { DEFAULT_TAB_ID } from '../../framework/Constants'; import { Assertions, Gestures, Matchers, Utilities } from '../../framework'; @@ -19,68 +20,68 @@ interface TransactionParams { } class Browser { - get reloadButton(): DetoxElement { + get reloadButton(): EncapsulatedElementType { return Matchers.getElementByID(BrowserViewSelectorsIDs.RELOAD_BUTTON); } - get bookmarkButton(): DetoxElement { + get bookmarkButton(): EncapsulatedElementType { return Matchers.getElementByID(BrowserViewSelectorsIDs.BOOKMARK_BUTTON); } - get newTabButton(): DetoxElement { + get newTabButton(): EncapsulatedElementType { return Matchers.getElementByID(BrowserViewSelectorsIDs.NEW_TAB_BUTTON); } - get closeBrowserButton(): DetoxElement { + get closeBrowserButton(): EncapsulatedElementType { return Matchers.getElementByID( BrowserViewSelectorsIDs.BROWSER_CLOSE_BUTTON, ); } // Legacy getters for backward compatibility with existing tests - get homeButton(): DetoxElement { + get homeButton(): EncapsulatedElementType { // Home button removed, but kept for backward compatibility // Tests using this should be updated return this.newTabButton; } - get browserScreenID(): DetoxElement { + get browserScreenID(): EncapsulatedElementType { return Matchers.getElementByID(BrowserViewSelectorsIDs.BROWSER_SCREEN_ID); } - get androidBrowserWebViewID(): DetoxElement { + get androidBrowserWebViewID(): EncapsulatedElementType { return Matchers.getElementByID(BrowserViewSelectorsIDs.BROWSER_WEBVIEW_ID); } - get addressBar(): DetoxElement { + get addressBar(): EncapsulatedElementType { return Matchers.getElementByID(BrowserViewSelectorsIDs.URL_INPUT); } - get urlInputBoxID(): DetoxElement { + get urlInputBoxID(): EncapsulatedElementType { return Matchers.getElementByID(BrowserURLBarSelectorsIDs.URL_INPUT); } - get clearURLButton(): DetoxElement { + get clearURLButton(): EncapsulatedElementType { return Matchers.getElementByID(BrowserURLBarSelectorsIDs.URL_CLEAR_ICON); } - get cancelUrlInputButton(): DetoxElement { + get cancelUrlInputButton(): EncapsulatedElementType { return Matchers.getElementByID( BrowserURLBarSelectorsIDs.CANCEL_BUTTON_ON_BROWSER_ID, ); } - get backToSafetyButton(): DetoxElement { + get backToSafetyButton(): EncapsulatedElementType { return Matchers.getElementByText( BrowserViewSelectorsText.BACK_TO_SAFETY_BUTTON, ); } - get returnHomeButton(): DetoxElement { + get returnHomeButton(): EncapsulatedElementType { return Matchers.getElementByText(BrowserViewSelectorsText.RETURN_HOME); } - get addFavouritesButton(): DetoxElement { + get addFavouritesButton(): EncapsulatedElementType { return Matchers.getElementByID(BrowserViewSelectorsIDs.ADD_NEW_TAB); } @@ -103,33 +104,33 @@ class Browser { ); } - get multiTabButton(): DetoxElement { + get multiTabButton(): EncapsulatedElementType { return Matchers.getElementByID(BrowserViewSelectorsIDs.ADD_NEW_TAB); } - get DefaultAvatarImageForLocalHost(): DetoxElement { + get DefaultAvatarImageForLocalHost(): EncapsulatedElementType { return Matchers.getElementByLabel('L'); } - get networkAvatarOrAccountButton(): DetoxElement { + get networkAvatarOrAccountButton(): EncapsulatedElementType { return Matchers.getElementByID(AccountOverviewSelectorsIDs.ACCOUNT_BUTTON); } - get addBookmarkButton(): DetoxElement { + get addBookmarkButton(): EncapsulatedElementType { return device.getPlatform() === 'ios' ? Matchers.getElementByID(AddBookmarkViewSelectorsIDs.CONFIRM_BUTTON) : Matchers.getElementByLabel(AddBookmarkViewSelectorsIDs.CONFIRM_BUTTON); } - get tabsNumber(): DetoxElement { + get tabsNumber(): EncapsulatedElementType { return Matchers.getElementByID(BrowserViewSelectorsIDs.TABS_NUMBER); } - get closeAllTabsButton(): DetoxElement { + get closeAllTabsButton(): EncapsulatedElementType { return Matchers.getElementByID(BrowserViewSelectorsIDs.CLOSE_ALL_TABS); } - get noTabsMessage(): DetoxElement { + get noTabsMessage(): EncapsulatedElementType { return Matchers.getElementByID(BrowserViewSelectorsIDs.NO_TABS_MESSAGE); } diff --git a/tests/page-objects/Browser/Confirmations/AlertSystem.ts b/tests/page-objects/Browser/Confirmations/AlertSystem.ts index 1591bbf37e8..fca2d1634a8 100644 --- a/tests/page-objects/Browser/Confirmations/AlertSystem.ts +++ b/tests/page-objects/Browser/Confirmations/AlertSystem.ts @@ -8,59 +8,60 @@ import { AlertTypeIDs, } from '../../../../app/components/Views/confirmations/ConfirmationView.testIds'; import Gestures from '../../../framework/Gestures'; +import { EncapsulatedElementType } from '../../../framework'; class AlertSystem { - get securityAlertBanner(): DetoxElement { + get securityAlertBanner(): EncapsulatedElementType { return Matchers.getElementByID( ConfirmationTopSheetSelectorsIDs.SECURITY_ALERT_BANNER_REDESIGNED, ); } - get securityAlertResponseFailedBanner(): DetoxElement { + get securityAlertResponseFailedBanner(): EncapsulatedElementType { return Matchers.getElementByText( ConfirmationTopSheetSelectorsText.BANNER_FAILED_TITLE, ); } - get securityAlertResponseMaliciousBanner(): DetoxElement { + get securityAlertResponseMaliciousBanner(): EncapsulatedElementType { return Matchers.getElementByText( ConfirmationTopSheetSelectorsText.BANNER_MALICIOUS_TITLE, ); } - get inlineAlert(): DetoxElement { + get inlineAlert(): EncapsulatedElementType { return Matchers.getElementByID(AlertTypeIDs.INLINE_ALERT); } - get alertMismatchTitle(): DetoxElement { + get alertMismatchTitle(): EncapsulatedElementType { return Matchers.getElementByText( AlertModalSelectorsText.ALERT_ORIGIN_MISMATCH_TITLE, ); } - get acknowledgeAlertModal(): DetoxElement { + get acknowledgeAlertModal(): EncapsulatedElementType { return Matchers.getElementByID(AlertModalSelectorsIDs.ALERT_MODAL_CHECKBOX); } - get acknowledgeAlertModalButton(): DetoxElement { + get acknowledgeAlertModalButton(): EncapsulatedElementType { return Matchers.getElementByID( AlertModalSelectorsIDs.ALERT_MODAL_ACKNOWLEDGE_BUTTON, ); } - get confirmAlertModal(): DetoxElement { + get confirmAlertModal(): EncapsulatedElementType { return Matchers.getElementByID( ConfirmAlertModalSelectorsIDs.CONFIRM_ALERT_MODAL, ); } - get confirmAlertModalButton(): DetoxElement { + get confirmAlertModalButton(): EncapsulatedElementType { return Matchers.getElementByID( ConfirmAlertModalSelectorsIDs.CONFIRM_ALERT_BUTTON, ); } - get acknowledgeConfirmAlertModal(): DetoxElement { + get acknowledgeConfirmAlertModal(): EncapsulatedElementType { return Matchers.getElementByID( ConfirmAlertModalSelectorsIDs.CONFIRM_ALERT_CHECKBOX, ); diff --git a/tests/page-objects/Browser/Confirmations/ConfirmationUITypes.ts b/tests/page-objects/Browser/Confirmations/ConfirmationUITypes.ts index 3af8723b5df..e3710fe225d 100644 --- a/tests/page-objects/Browser/Confirmations/ConfirmationUITypes.ts +++ b/tests/page-objects/Browser/Confirmations/ConfirmationUITypes.ts @@ -1,12 +1,13 @@ import { ConfirmationUIType } from '../../../../app/components/Views/confirmations/ConfirmationView.testIds'; import Matchers from '../../../framework/Matchers'; +import { EncapsulatedElementType } from '../../../framework'; class ConfirmationUITypes { - get ModalConfirmationContainer(): DetoxElement { + get ModalConfirmationContainer(): EncapsulatedElementType { return Matchers.getElementByID(ConfirmationUIType.MODAL); } - get FlatConfirmationContainer(): DetoxElement { + get FlatConfirmationContainer(): EncapsulatedElementType { return Matchers.getElementByID(ConfirmationUIType.FLAT); } } diff --git a/tests/page-objects/Browser/Confirmations/FooterActions.ts b/tests/page-objects/Browser/Confirmations/FooterActions.ts index 906abbbed2f..b2dcfeface9 100644 --- a/tests/page-objects/Browser/Confirmations/FooterActions.ts +++ b/tests/page-objects/Browser/Confirmations/FooterActions.ts @@ -3,17 +3,18 @@ import Matchers from '../../../framework/Matchers'; import Gestures from '../../../framework/Gestures'; import TestHelpers from '../../../helpers'; import { encapsulatedAction } from '../../../framework/encapsulatedAction'; +import { EncapsulatedElementType } from '../../../framework/EncapsulatedElement'; import PlaywrightMatchers from '../../../framework/PlaywrightMatchers'; import PlaywrightGestures from '../../../framework/PlaywrightGestures'; class FooterActions { - get confirmButton(): DetoxElement { + get confirmButton(): EncapsulatedElementType { return Matchers.getElementByID( ConfirmationFooterSelectorIDs.CONFIRM_BUTTON, ); } - get cancelButton(): DetoxElement { + get cancelButton(): EncapsulatedElementType { return Matchers.getElementByID(ConfirmationFooterSelectorIDs.CANCEL_BUTTON); } diff --git a/tests/page-objects/Browser/Confirmations/RequestTypes.ts b/tests/page-objects/Browser/Confirmations/RequestTypes.ts index 88b2fdd2aa6..506ac6d2bd7 100644 --- a/tests/page-objects/Browser/Confirmations/RequestTypes.ts +++ b/tests/page-objects/Browser/Confirmations/RequestTypes.ts @@ -1,14 +1,15 @@ import { ConfirmationRequestTypeIDs } from '../../../../app/components/Views/confirmations/ConfirmationView.testIds'; import Matchers from '../../../framework/Matchers'; +import { EncapsulatedElementType } from '../../../framework'; class RequestTypes { - get PersonalSignRequest(): DetoxElement { + get PersonalSignRequest(): EncapsulatedElementType { return Matchers.getElementByID( ConfirmationRequestTypeIDs.PERSONAL_SIGN_REQUEST, ); } - get TypedSignRequest(): DetoxElement { + get TypedSignRequest(): EncapsulatedElementType { return Matchers.getElementByID( ConfirmationRequestTypeIDs.TYPED_SIGN_REQUEST, ); diff --git a/tests/page-objects/Browser/Confirmations/RowComponents.ts b/tests/page-objects/Browser/Confirmations/RowComponents.ts index 72984d7b29b..b20bf69ce7a 100644 --- a/tests/page-objects/Browser/Confirmations/RowComponents.ts +++ b/tests/page-objects/Browser/Confirmations/RowComponents.ts @@ -3,79 +3,80 @@ import { GasFeeTokenSelectorIDs, } from '../../../../app/components/Views/confirmations/ConfirmationView.testIds'; import Matchers from '../../../framework/Matchers'; +import { EncapsulatedElementType } from '../../../framework'; class RowComponents { - get AccountNetwork(): DetoxElement { + get AccountNetwork(): EncapsulatedElementType { return Matchers.getElementByID(ConfirmationRowComponentIDs.ACCOUNT_NETWORK); } - get AdvancedDetails(): DetoxElement { + get AdvancedDetails(): EncapsulatedElementType { return Matchers.getElementByID( ConfirmationRowComponentIDs.ADVANCED_DETAILS, ); } - get FromTo(): DetoxElement { + get FromTo(): EncapsulatedElementType { return Matchers.getElementByID(ConfirmationRowComponentIDs.FROM_TO); } - get GasFeesDetails(): DetoxElement { + get GasFeesDetails(): EncapsulatedElementType { return Matchers.getElementByID( ConfirmationRowComponentIDs.GAS_FEES_DETAILS, ); } - get Message(): DetoxElement { + get Message(): EncapsulatedElementType { return Matchers.getElementByID(ConfirmationRowComponentIDs.MESSAGE); } - get OriginInfo(): DetoxElement { + get OriginInfo(): EncapsulatedElementType { return Matchers.getElementByID(ConfirmationRowComponentIDs.ORIGIN_INFO); } - get SimulationDetails(): DetoxElement { + get SimulationDetails(): EncapsulatedElementType { return Matchers.getElementByID( ConfirmationRowComponentIDs.SIMULATION_DETAILS, ); } - get SiweSigningAccountInfo(): DetoxElement { + get SiweSigningAccountInfo(): EncapsulatedElementType { return Matchers.getElementByID( ConfirmationRowComponentIDs.SIWE_SIGNING_ACCOUNT_INFO, ); } - get TokenHero(): DetoxElement { + get TokenHero(): EncapsulatedElementType { return Matchers.getElementByID(ConfirmationRowComponentIDs.TOKEN_HERO); } - get ApproveRow(): DetoxElement { + get ApproveRow(): EncapsulatedElementType { return Matchers.getElementByID(ConfirmationRowComponentIDs.APPROVE_ROW); } - get NetworkAndOrigin(): DetoxElement { + get NetworkAndOrigin(): EncapsulatedElementType { return Matchers.getElementByID(ConfirmationRowComponentIDs.NETWORK); } - get NetworkFeePaidByMetaMask(): DetoxElement { + get NetworkFeePaidByMetaMask(): EncapsulatedElementType { return Matchers.getElementByID( ConfirmationRowComponentIDs.PAID_BY_METAMASK, ); } - get NetworkFeeGasFeeTokenPill(): DetoxElement { + get NetworkFeeGasFeeTokenPill(): EncapsulatedElementType { return Matchers.getElementByID( ConfirmationRowComponentIDs.GAS_FEE_TOKEN_PILL, ); } - get NetworkFeeGasFeeTokenSymbol(): DetoxElement { + get NetworkFeeGasFeeTokenSymbol(): EncapsulatedElementType { return Matchers.getElementByID( GasFeeTokenSelectorIDs.SELECTED_GAS_FEE_TOKEN_SYMBOL, ); } - get NetworkFeeGasFeeTokenArrow(): DetoxElement { + get NetworkFeeGasFeeTokenArrow(): EncapsulatedElementType { return Matchers.getElementByID( GasFeeTokenSelectorIDs.SELECTED_GAS_FEE_TOKEN_ARROW, ); diff --git a/tests/page-objects/Browser/ConnectBottomSheet.ts b/tests/page-objects/Browser/ConnectBottomSheet.ts index 512d3f08635..e5847b3697a 100644 --- a/tests/page-objects/Browser/ConnectBottomSheet.ts +++ b/tests/page-objects/Browser/ConnectBottomSheet.ts @@ -4,45 +4,46 @@ import { } from '../../../app/components/Views/MultichainAccounts/shared/ConnectAccountBottomSheet.testIds'; import Matchers from '../../framework/Matchers'; import Gestures from '../../framework/Gestures'; +import { EncapsulatedElementType } from '../../framework/EncapsulatedElement'; import { CommonSelectorsIDs } from '../../../app/util/Common.testIds'; class ConnectBottomSheet { - get container(): DetoxElement { + get container(): EncapsulatedElementType { return Matchers.getElementByID( ConnectAccountBottomSheetSelectorsIDs.CONTAINER, ); } - get connectButton(): DetoxElement { + get connectButton(): EncapsulatedElementType { return device.getPlatform() === 'android' ? Matchers.getElementByLabel(CommonSelectorsIDs.CONNECT_BUTTON) : Matchers.getElementByID(CommonSelectorsIDs.CONNECT_BUTTON); } - get connectAccountsButton(): DetoxElement { + get connectAccountsButton(): EncapsulatedElementType { return Matchers.getElementByText( ConnectAccountBottomSheetSelectorsText.CONNECT_ACCOUNTS, ); } - get importButton(): DetoxElement { + get importButton(): EncapsulatedElementType { return Matchers.getElementByText( ConnectAccountBottomSheetSelectorsText.IMPORT_ACCOUNT, ); } - get selectAllButton(): DetoxElement { + get selectAllButton(): EncapsulatedElementType { return Matchers.getElementByText( ConnectAccountBottomSheetSelectorsText.SELECT_ALL, ); } - get selectMultiButton(): DetoxElement { + get selectMultiButton(): EncapsulatedElementType { return Matchers.getElementByID( ConnectAccountBottomSheetSelectorsIDs.SELECT_MULTI_BUTTON, ); } - get cancelButton(): DetoxElement { + get cancelButton(): EncapsulatedElementType { return Matchers.getElementByID( ConnectAccountBottomSheetSelectorsIDs.CANCEL_BUTTON, ); diff --git a/tests/page-objects/Browser/ConnectedAccountsModal.ts b/tests/page-objects/Browser/ConnectedAccountsModal.ts index d1c82533ef2..c912d84b1f3 100644 --- a/tests/page-objects/Browser/ConnectedAccountsModal.ts +++ b/tests/page-objects/Browser/ConnectedAccountsModal.ts @@ -11,102 +11,103 @@ import type { NativeElement, IndexableSystemElement, } from 'detox/detox'; +import { EncapsulatedElementType } from '../../framework'; type DetoxElement = Promise< IndexableNativeElement | NativeElement | IndexableSystemElement >; class ConnectedAccountsModal { - get container(): DetoxElement { + get container(): EncapsulatedElementType { return Matchers.getElementByID(ConnectedAccountsSelectorsIDs.CONTAINER); } - get permissionsButton(): DetoxElement { + get permissionsButton(): EncapsulatedElementType { return Matchers.getElementByText( ConnectedAccountModalSelectorsText.PERMISSION_LINK, ); } - get networkPicker(): DetoxElement { + get networkPicker(): EncapsulatedElementType { return Matchers.getElementByID( ConnectedAccountsSelectorsIDs.NETWORK_PICKER, ); } - get disconnectAllButton(): DetoxElement { + get disconnectAllButton(): EncapsulatedElementType { return Matchers.getElementByText( ConnectedAccountModalSelectorsText.DISCONNECT_ALL, ); } - get disconnectButton(): DetoxElement { + get disconnectButton(): EncapsulatedElementType { return Matchers.getElementByID(ConnectedAccountsSelectorsIDs.DISCONNECT); } - get disconnectAllAccountsAndNetworksButton(): DetoxElement { + get disconnectAllAccountsAndNetworksButton(): EncapsulatedElementType { return Matchers.getElementByID( ConnectedAccountsSelectorsIDs.DISCONNECT_ALL_ACCOUNTS_NETWORKS, ); } - get navigateToEditAccountsPermissionsButton(): DetoxElement { + get navigateToEditAccountsPermissionsButton(): EncapsulatedElementType { return Matchers.getElementByID( ConnectedAccountsSelectorsIDs.NAVIGATE_TO_EDIT_ACCOUNTS_PERMISSIONS_BUTTON, ); } - get navigateToEditNetworksPermissionsButton(): DetoxElement { + get navigateToEditNetworksPermissionsButton(): EncapsulatedElementType { return Matchers.getElementByID( ConnectedAccountsSelectorsIDs.NAVIGATE_TO_EDIT_NETWORKS_PERMISSIONS_BUTTON, ); } - get connectAccountsButton(): DetoxElement { + get connectAccountsButton(): EncapsulatedElementType { return Matchers.getElementByID( ConnectedAccountsSelectorsIDs.CONNECT_ACCOUNTS_BUTTON, ); } - get managePermissionsButton(): DetoxElement { + get managePermissionsButton(): EncapsulatedElementType { return Matchers.getElementByID( ConnectedAccountsSelectorsIDs.MANAGE_PERMISSIONS, ); } - get permissionsSummaryTab(): DetoxElement { + get permissionsSummaryTab(): EncapsulatedElementType { return Matchers.getElementByText( WalletViewSelectorsText.PERMISSIONS_SUMMARY_TAB, ); } - get accountsSummaryTab(): DetoxElement { + get accountsSummaryTab(): EncapsulatedElementType { return Matchers.getElementByText( WalletViewSelectorsText.ACCOUNTS_SUMMARY_TAB, ); } - get accountListBottomSheet(): DetoxElement { + get accountListBottomSheet(): EncapsulatedElementType { return Matchers.getElementByID( ConnectedAccountsSelectorsIDs.ACCOUNT_LIST_BOTTOM_SHEET, ); } - get title(): DetoxElement { + get title(): EncapsulatedElementType { return Matchers.getElementByText(ConnectedAccountModalSelectorsText.TITLE); } - get selectAllNetworksButton(): DetoxElement { + get selectAllNetworksButton(): EncapsulatedElementType { return Matchers.getElementByText( ConnectedAccountModalSelectorsText.SELECT_ALL, ); } - get disconnectNetworksButton(): DetoxElement { + get disconnectNetworksButton(): EncapsulatedElementType { return Matchers.getElementByID( ConnectedAccountsSelectorsIDs.DISCONNECT_NETWORKS_BUTTON, ); } - get confirmDisconnectNetworksButton(): DetoxElement { + get confirmDisconnectNetworksButton(): EncapsulatedElementType { return Matchers.getElementByID( ConnectedAccountsSelectorsIDs.CONFIRM_DISCONNECT_NETWORKS_BUTTON, ); @@ -243,7 +244,9 @@ class ConnectedAccountsModal { for (const accountName of possibleAccountNames) { try { - const textElement = await Matchers.getElementByText(accountName); + const textElement = (await Matchers.getElementByText( + accountName, + )) as Detox.NativeElement; await waitFor(textElement).toBeVisible().withTimeout(1000); displayedAccounts.push(accountName); } catch (e) { diff --git a/tests/page-objects/Browser/ContractApprovalBottomSheet.ts b/tests/page-objects/Browser/ContractApprovalBottomSheet.ts index 9c746366cc9..eba72e84d4e 100644 --- a/tests/page-objects/Browser/ContractApprovalBottomSheet.ts +++ b/tests/page-objects/Browser/ContractApprovalBottomSheet.ts @@ -9,6 +9,7 @@ // } from '../../../app/components/Views/confirmations/legacy/components/ContractApprovalBottomSheet.testIds'; import Matchers from '../../framework/Matchers'; import Gestures from '../../framework/Gestures'; +import { EncapsulatedElementType } from '../../framework'; // Temporary placeholders to prevent TypeScript errors const ContractApprovalBottomSheetSelectorsIDs = { @@ -27,55 +28,55 @@ const ContractApprovalBottomSheetSelectorsText = { }; class ContractApprovalBottomSheet { - get container(): DetoxElement { + get container(): EncapsulatedElementType { return Matchers.getElementByID( ContractApprovalBottomSheetSelectorsIDs.CONTAINER, ); } - get addNickName(): DetoxElement { + get addNickName(): EncapsulatedElementType { return Matchers.getElementByText( ContractApprovalBottomSheetSelectorsText.ADD_NICKNAME, ); } - get editNickName(): DetoxElement { + get editNickName(): EncapsulatedElementType { return Matchers.getElementByText( ContractApprovalBottomSheetSelectorsText.EDIT_NICKNAME, ); } - get rejectButton(): DetoxElement { + get rejectButton(): EncapsulatedElementType { return Matchers.getElementByText( ContractApprovalBottomSheetSelectorsText.REJECT, ); } - get approveButton(): DetoxElement { + get approveButton(): EncapsulatedElementType { return Matchers.getElementByText( ContractApprovalBottomSheetSelectorsText.APPROVE, ); } - get contractAddress(): DetoxElement { + get contractAddress(): EncapsulatedElementType { return Matchers.getElementByID( ContractApprovalBottomSheetSelectorsIDs.CONTRACT_ADDRESS, ); } - get nextButton(): DetoxElement { + get nextButton(): EncapsulatedElementType { return Matchers.getElementByText( ContractApprovalBottomSheetSelectorsText.NEXT, ); } - get approveTokenAmount(): DetoxElement { + get approveTokenAmount(): EncapsulatedElementType { return Matchers.getElementByID( ContractApprovalBottomSheetSelectorsIDs.APPROVE_TOKEN_AMOUNT, ); } - get confirmButton(): DetoxElement { + get confirmButton(): EncapsulatedElementType { return Matchers.getElementByText( ContractApprovalBottomSheetSelectorsText.CONFIRM, ); diff --git a/tests/page-objects/Browser/DownloadFile.ts b/tests/page-objects/Browser/DownloadFile.ts index 4b0965c1f9a..0e616b48c7e 100644 --- a/tests/page-objects/Browser/DownloadFile.ts +++ b/tests/page-objects/Browser/DownloadFile.ts @@ -9,17 +9,21 @@ class DownloadFile { device.getPlatform() === 'android' ? Matchers.getElementByText('Download') : Matchers.getElementByLabel('Download'); - await (await downloadButtonInDialog).tap(); + await ((await downloadButtonInDialog) as Detox.NativeElement).tap(); } async verifySuccessStateVisible(): Promise { if (device.getPlatform() === 'ios') { // Verify for iOS that system file saving dialog is visible - waitFor(await Matchers.getElementByLabel('Save')).toExist(); + waitFor( + (await Matchers.getElementByLabel('Save')) as Detox.NativeElement, + ).toExist(); } else { // Verify for Android that toast after successful downloading is visible waitFor( - await Matchers.getElementByText('Downloaded successfully'), + (await Matchers.getElementByText( + 'Downloaded successfully', + )) as Detox.NativeElement, ).toExist(); } } diff --git a/tests/page-objects/Browser/ExternalWebsites/Security/CameraWebsite.ts b/tests/page-objects/Browser/ExternalWebsites/Security/CameraWebsite.ts index 016ede9b28e..6969ee59aaa 100644 --- a/tests/page-objects/Browser/ExternalWebsites/Security/CameraWebsite.ts +++ b/tests/page-objects/Browser/ExternalWebsites/Security/CameraWebsite.ts @@ -7,9 +7,9 @@ class CameraWebsite { async verifyRequestPermissionDialogVisible(): Promise { if (device.getPlatform() === 'ios') { await waitFor( - await Matchers.getElementByLabel( + (await Matchers.getElementByLabel( 'Allow "localhost" to use your camera?', - ), + )) as Detox.NativeElement, ).toExist(); // The WKWebView permission prompt is part of the app view hierarchy, // not a system alert. Multiple elements may match "Allow", so pick diff --git a/tests/page-objects/Browser/NetworkConnectMultiSelector.ts b/tests/page-objects/Browser/NetworkConnectMultiSelector.ts index 4e3f12d17d0..00294776609 100644 --- a/tests/page-objects/Browser/NetworkConnectMultiSelector.ts +++ b/tests/page-objects/Browser/NetworkConnectMultiSelector.ts @@ -1,22 +1,22 @@ import { NetworkConnectMultiSelectorSelectorsIDs } from '../../../app/components/Views/NetworkConnect/NetworkConnectMultiSelector.testIds'; import Matchers from '../../framework/Matchers'; import Gestures from '../../framework/Gestures'; -import { Assertions } from '../../framework'; +import { Assertions, EncapsulatedElementType } from '../../framework'; class NetworkConnectMultiSelector { - get updateButton(): DetoxElement { + get updateButton(): EncapsulatedElementType { return Matchers.getElementByID( NetworkConnectMultiSelectorSelectorsIDs.UPDATE_CHAIN_PERMISSIONS, ); } - get backButton(): DetoxElement { + get backButton(): EncapsulatedElementType { return Matchers.getElementByID( NetworkConnectMultiSelectorSelectorsIDs.BACK_BUTTON, ); } - getMultiselectElement(label: string): DetoxElement { + getMultiselectElement(label: string): EncapsulatedElementType { return Matchers.getElementByLabel(label); } @@ -34,7 +34,7 @@ class NetworkConnectMultiSelector { async isNetworkChainPermissionSelected(chainName: string): Promise { const chainPermissionTestId = `${chainName}-selected`; - const el = await Matchers.getElementByID(chainPermissionTestId); + const el = Matchers.getElementByID(chainPermissionTestId); await Assertions.expectElementToBeVisible(el, { timeout: 10000, description: `Network chain permission ${chainName} should be selected`, @@ -43,7 +43,7 @@ class NetworkConnectMultiSelector { async isNetworkChainPermissionNotSelected(chainName: string): Promise { const chainPermissionTestId = `${chainName}-not-selected`; - const el = await Matchers.getElementByID(chainPermissionTestId); + const el = Matchers.getElementByID(chainPermissionTestId); await Assertions.expectElementToBeVisible(el, { timeout: 10000, description: `Network chain permission ${chainName} should be selected`, diff --git a/tests/page-objects/Browser/PermissionSummaryBottomSheet.ts b/tests/page-objects/Browser/PermissionSummaryBottomSheet.ts index 684739ea828..ad2446ab941 100644 --- a/tests/page-objects/Browser/PermissionSummaryBottomSheet.ts +++ b/tests/page-objects/Browser/PermissionSummaryBottomSheet.ts @@ -4,38 +4,39 @@ import { } from '../../../app/components/Views/MultichainAccounts/shared/PermissionSummaryBottomSheet.testIds'; import Matchers from '../../framework/Matchers'; import Gestures from '../../framework/Gestures'; +import { EncapsulatedElementType } from '../../framework'; class PermissionSummaryBottomSheet { - get container(): DetoxElement { + get container(): EncapsulatedElementType { return Matchers.getElementByID( PermissionSummaryBottomSheetSelectorsIDs.CONTAINER, ); } - get addNetworkPermissionContainer(): DetoxElement { + get addNetworkPermissionContainer(): EncapsulatedElementType { return Matchers.getElementByID( PermissionSummaryBottomSheetSelectorsIDs.NETWORK_PERMISSIONS_CONTAINER, ); } - get backButton(): DetoxElement { + get backButton(): EncapsulatedElementType { return Matchers.getElementByID( PermissionSummaryBottomSheetSelectorsIDs.BACK_BUTTON, ); } - get connectedAccountsText(): DetoxElement { + get connectedAccountsText(): EncapsulatedElementType { return Matchers.getElementByText( PermissionSummaryBottomSheetSelectorsText.CONNECTED_ACCOUNTS_TEXT, ); } - get ethereumMainnetText(): DetoxElement { + get ethereumMainnetText(): EncapsulatedElementType { return Matchers.getElementByText( PermissionSummaryBottomSheetSelectorsText.ETHEREUM_MAINNET_LABEL, ); } - get accountPermissionLabelContainer(): DetoxElement { + get accountPermissionLabelContainer(): EncapsulatedElementType { return Matchers.getElementByID( PermissionSummaryBottomSheetSelectorsIDs.ACCOUNT_PERMISSION_CONTAINER, ); diff --git a/tests/page-objects/Browser/SigningBottomSheet.ts b/tests/page-objects/Browser/SigningBottomSheet.ts index f46d62c5ba4..111f96b91fa 100644 --- a/tests/page-objects/Browser/SigningBottomSheet.ts +++ b/tests/page-objects/Browser/SigningBottomSheet.ts @@ -1,25 +1,26 @@ import { SigningBottomSheetSelectorsIDs } from '../../../app/components/Views/confirmations/legacy/components/SigningBottomSheet.testIds'; import Matchers from '../../framework/Matchers'; import Gestures from '../../framework/Gestures'; +import { EncapsulatedElementType } from '../../framework'; class SigningBottomSheet { - get signButton(): DetoxElement { + get signButton(): EncapsulatedElementType { return Matchers.getElementByID(SigningBottomSheetSelectorsIDs.SIGN_BUTTON); } - get cancelButton(): DetoxElement { + get cancelButton(): EncapsulatedElementType { return Matchers.getElementByID( SigningBottomSheetSelectorsIDs.CANCEL_BUTTON, ); } - get personalRequest(): DetoxElement { + get personalRequest(): EncapsulatedElementType { return Matchers.getElementByID( SigningBottomSheetSelectorsIDs.PERSONAL_REQUEST, ); } - get typedRequest(): DetoxElement { + get typedRequest(): EncapsulatedElementType { return Matchers.getElementByID( SigningBottomSheetSelectorsIDs.TYPED_REQUEST, ); diff --git a/tests/page-objects/Browser/SpamFilterModal.ts b/tests/page-objects/Browser/SpamFilterModal.ts index 2e64d80c64f..ee350d25e9a 100644 --- a/tests/page-objects/Browser/SpamFilterModal.ts +++ b/tests/page-objects/Browser/SpamFilterModal.ts @@ -1,13 +1,14 @@ import Matchers from '../../framework/Matchers'; import Gestures from '../../framework/Gestures'; import { SpamFilterModalSelectorText } from '../../selectors/Browser/SpamFilterModal.selectors'; +import { EncapsulatedElementType } from '../../framework'; class SpamFilterModal { - get title(): DetoxElement { + get title(): EncapsulatedElementType { return Matchers.getElementByText(SpamFilterModalSelectorText.TITLE); } - get cancelButtonText(): DetoxElement { + get cancelButtonText(): EncapsulatedElementType { return Matchers.getElementByText(SpamFilterModalSelectorText.CANCEL_BUTTON); } diff --git a/tests/page-objects/Browser/TestDApp.ts b/tests/page-objects/Browser/TestDApp.ts index af94f18a47a..998f9914cd3 100644 --- a/tests/page-objects/Browser/TestDApp.ts +++ b/tests/page-objects/Browser/TestDApp.ts @@ -3,6 +3,7 @@ import enContent from '../../../locales/languages/en.json'; import Gestures from '../../framework/Gestures'; import Matchers from '../../framework/Matchers'; import { getTestDappLocalUrl } from '../../framework/fixtures/FixtureUtils'; +import { EncapsulatedElementType } from '../../framework/EncapsulatedElement'; import { BrowserViewSelectorsIDs } from '../../../app/components/Views/BrowserTab/BrowserView.testIds'; import { TestDappSelectorsWebIDs } from '../../selectors/Browser/TestDapp.selectors'; import Browser from './BrowserView'; @@ -18,11 +19,11 @@ interface ContractNavigationParams { } class TestDApp { - get confirmButtonText(): DetoxElement { + get confirmButtonText(): EncapsulatedElementType { return Matchers.getElementByText(CONFIRM_BUTTON_TEXT); } - get approveButtonText(): DetoxElement { + get approveButtonText(): EncapsulatedElementType { return Matchers.getElementByText(APPROVE_BUTTON_TEXT); } diff --git a/tests/page-objects/Browser/TestSnaps.ts b/tests/page-objects/Browser/TestSnaps.ts index 2691123352d..bf6de80d02b 100644 --- a/tests/page-objects/Browser/TestSnaps.ts +++ b/tests/page-objects/Browser/TestSnaps.ts @@ -20,7 +20,7 @@ import { IndexableWebElement } from 'detox/detox'; import Utilities from '../../framework/Utilities'; import { ConfirmationFooterSelectorIDs } from '../../../app/components/Views/confirmations/ConfirmationView.testIds'; import { waitForTestSnapsToLoad } from '../../flows/browser.flow'; -import { RetryOptions } from '../../framework'; +import { RetryOptions, EncapsulatedElementType } from '../../framework'; import { Json } from '@metamask/utils'; import ToastModal from '../wallet/ToastModal'; import SolanaTestDApp from './SolanaTestDApp'; @@ -29,65 +29,65 @@ export const TEST_SNAPS_URL = 'https://metamask.github.io/snaps/test-snaps/3.4.2/'; class TestSnaps { - get getConnectSnapButton(): DetoxElement { + get getConnectSnapButton(): EncapsulatedElementType { return Matchers.getElementByID(SNAP_INSTALL_CONNECT); } - get getApproveSnapPermissionsRequestButton(): DetoxElement { + get getApproveSnapPermissionsRequestButton(): EncapsulatedElementType { return Matchers.getElementByID(SNAP_INSTALL_PERMISSIONS_REQUEST_APPROVE); } - get getConnectSnapInstallOkButton(): DetoxElement { + get getConnectSnapInstallOkButton(): EncapsulatedElementType { return Matchers.getElementByID(SNAP_INSTALL_OK); } - get getApproveSignRequestButton(): DetoxElement { + get getApproveSignRequestButton(): EncapsulatedElementType { return Matchers.getElementByID( TestSnapBottomSheetSelectorWebIDS.BOTTOMSHEET_FOOTER_BUTTON_ID, ); } - get confirmSignatureButton(): DetoxElement { + get confirmSignatureButton(): EncapsulatedElementType { return Matchers.getElementByID( ConfirmationFooterSelectorIDs.CONFIRM_BUTTON, ); } - get solanaConfirmButton(): DetoxElement { + get solanaConfirmButton(): EncapsulatedElementType { return Matchers.getElementByID( 'confirm-sign-message-confirm-snap-footer-button', ); } - get footerButton(): DetoxElement { + get footerButton(): EncapsulatedElementType { return Matchers.getElementByID( TestSnapBottomSheetSelectorWebIDS.DEFAULT_FOOTER_BUTTON_ID, ); } - get checkboxElement(): DetoxElement { + get checkboxElement(): EncapsulatedElementType { return Matchers.getElementByID('snap-ui-renderer__checkbox'); } - get dateTimePickerTouchable(): DetoxElement { + get dateTimePickerTouchable(): EncapsulatedElementType { return Matchers.getElementByID( 'snap-ui-renderer__date-time-picker--datetime-touchable', ); } - get datePickerTouchable(): DetoxElement { + get datePickerTouchable(): EncapsulatedElementType { return Matchers.getElementByID( 'snap-ui-renderer__date-time-picker--date-touchable', ); } - get timePickerTouchable(): DetoxElement { + get timePickerTouchable(): EncapsulatedElementType { return Matchers.getElementByID( 'snap-ui-renderer__date-time-picker--time-touchable', ); } - get dateTimePickerOkButton(): DetoxElement { + get dateTimePickerOkButton(): EncapsulatedElementType { return Matchers.getElementByText('OK'); } diff --git a/tests/page-objects/Card/CardHomeView.ts b/tests/page-objects/Card/CardHomeView.ts index d3256792879..596674283f5 100644 --- a/tests/page-objects/Card/CardHomeView.ts +++ b/tests/page-objects/Card/CardHomeView.ts @@ -1,41 +1,42 @@ import Matchers from '../../framework/Matchers'; import Gestures from '../../framework/Gestures'; import { CardHomeSelectors } from '../../../app/components/UI/Card/Views/CardHome/CardHome.testIds'; +import { EncapsulatedElementType } from '../../framework'; class CardHomeView { - get tryAgainButton(): DetoxElement { + get tryAgainButton(): EncapsulatedElementType { return Matchers.getElementByID(CardHomeSelectors.TRY_AGAIN_BUTTON); } - get privacyToggleButton(): DetoxElement { + get privacyToggleButton(): EncapsulatedElementType { return Matchers.getElementByID(CardHomeSelectors.PRIVACY_TOGGLE_BUTTON); } - get addFundsButton(): DetoxElement { + get addFundsButton(): EncapsulatedElementType { return Matchers.getElementByID(CardHomeSelectors.ADD_FUNDS_BUTTON); } - get addFundsBottomSheet(): DetoxElement { + get addFundsBottomSheet(): EncapsulatedElementType { return Matchers.getElementByID(CardHomeSelectors.ADD_FUNDS_BOTTOM_SHEET); } - get addFundsBottomSheetDepositOption(): DetoxElement { + get addFundsBottomSheetDepositOption(): EncapsulatedElementType { return Matchers.getElementByID( CardHomeSelectors.ADD_FUNDS_BOTTOM_SHEET_DEPOSIT_OPTION, ); } - get addFundsBottomSheetSwapOption(): DetoxElement { + get addFundsBottomSheetSwapOption(): EncapsulatedElementType { return Matchers.getElementByID( CardHomeSelectors.ADD_FUNDS_BOTTOM_SHEET_SWAP_OPTION, ); } - get cardViewTitle(): DetoxElement { + get cardViewTitle(): EncapsulatedElementType { return Matchers.getElementByID(CardHomeSelectors.CARD_VIEW_TITLE); } - get swapScreenSourceTokenArea(): DetoxElement { + get swapScreenSourceTokenArea(): EncapsulatedElementType { return Matchers.getElementByID('source-token-area'); } diff --git a/tests/page-objects/Common/ForgotPasswordModalView.ts b/tests/page-objects/Common/ForgotPasswordModalView.ts index 8d581a98266..160ca002f25 100644 --- a/tests/page-objects/Common/ForgotPasswordModalView.ts +++ b/tests/page-objects/Common/ForgotPasswordModalView.ts @@ -5,75 +5,76 @@ import { import Matchers from '../../framework/Matchers'; import Gestures from '../../framework/Gestures'; import { OnboardingSelectorText } from '../../../app/components/Views/Onboarding/Onboarding.testIds'; +import { EncapsulatedElementType } from '../../framework'; class ForgotPasswordModalView { - get container(): DetoxElement { + get container(): EncapsulatedElementType { return Matchers.getElementByID(ForgotPasswordModalSelectorsIDs.CONTAINER); } - get title(): DetoxElement { + get title(): EncapsulatedElementType { return Matchers.getElementByID(ForgotPasswordModalSelectorsIDs.TITLE); } - get description(): DetoxElement { + get description(): EncapsulatedElementType { return Matchers.getElementByID(ForgotPasswordModalSelectorsIDs.DESCRIPTION); } - get resetWalletButton(): DetoxElement { + get resetWalletButton(): EncapsulatedElementType { return Matchers.getElementByID( ForgotPasswordModalSelectorsIDs.RESET_WALLET_BUTTON, ); } - get yesResetWalletButton(): DetoxElement { + get yesResetWalletButton(): EncapsulatedElementType { return Matchers.getElementByID( ForgotPasswordModalSelectorsIDs.YES_RESET_WALLET_BUTTON, ); } - get cancelButton(): DetoxElement { + get cancelButton(): EncapsulatedElementType { return Matchers.getElementByID( ForgotPasswordModalSelectorsIDs.CANCEL_BUTTON, ); } - get warningText(): DetoxElement { + get warningText(): EncapsulatedElementType { return Matchers.getElementByID( ForgotPasswordModalSelectorsIDs.WARNING_TEXT, ); } - get titleText(): DetoxElement { + get titleText(): EncapsulatedElementType { return Matchers.getElementByText(ForgotPasswordModalSelectorsText.TITLE); } - get descriptionText(): DetoxElement { + get descriptionText(): EncapsulatedElementType { return Matchers.getElementByText( ForgotPasswordModalSelectorsText.DESCRIPTION, ); } - get resetWalletText(): DetoxElement { + get resetWalletText(): EncapsulatedElementType { return Matchers.getElementByText( ForgotPasswordModalSelectorsText.RESET_WALLET, ); } - get yesResetWalletText(): DetoxElement { + get yesResetWalletText(): EncapsulatedElementType { return Matchers.getElementByText( ForgotPasswordModalSelectorsText.YES_RESET_WALLET, ); } - get cancelText(): DetoxElement { + get cancelText(): EncapsulatedElementType { return Matchers.getElementByText(ForgotPasswordModalSelectorsText.CANCEL); } - get warningTextContent(): DetoxElement { + get warningTextContent(): EncapsulatedElementType { return Matchers.getElementByText(ForgotPasswordModalSelectorsText.WARNING); } - get successBottomNotification(): DetoxElement { + get successBottomNotification(): EncapsulatedElementType { return Matchers.getElementByText( OnboardingSelectorText.SUCCESSFUL_WALLET_RESET, ); diff --git a/tests/page-objects/CommonView.ts b/tests/page-objects/CommonView.ts index b02157c1c5e..b98febbd85b 100644 --- a/tests/page-objects/CommonView.ts +++ b/tests/page-objects/CommonView.ts @@ -4,21 +4,22 @@ import { CommonSelectorsIDs, CommonSelectorsText, } from '../../app/util/Common.testIds'; +import { EncapsulatedElementType } from '../framework'; class CommonView { - get okAlertByText(): DetoxElement { + get okAlertByText(): EncapsulatedElementType { return Matchers.getElementByText('OK'); } - get backButton(): DetoxElement { + get backButton(): EncapsulatedElementType { return Matchers.getElementByID(CommonSelectorsIDs.BACK_ARROW_BUTTON); } - get errorMessage(): DetoxElement { + get errorMessage(): EncapsulatedElementType { return Matchers.getElementByID(CommonSelectorsIDs.ERROR_MESSAGE); } - get okAlertButton(): DetoxElement { + get okAlertButton(): EncapsulatedElementType { return Matchers.getElementByText(CommonSelectorsText.OK_ALERT_BUTTON); } diff --git a/tests/page-objects/Confirmation/ConfirmationView.ts b/tests/page-objects/Confirmation/ConfirmationView.ts index 2cbdb81f046..466a650f499 100644 --- a/tests/page-objects/Confirmation/ConfirmationView.ts +++ b/tests/page-objects/Confirmation/ConfirmationView.ts @@ -1,14 +1,15 @@ import { ConfirmationTopSheetSelectorsIDs } from '../../../app/components/Views/confirmations/ConfirmationView.testIds'; import Matchers from '../../framework/Matchers'; +import { EncapsulatedElementType } from '../../framework'; class ConfirmationView { - get securityAlertBanner(): DetoxElement { + get securityAlertBanner(): EncapsulatedElementType { return Matchers.getElementByID( ConfirmationTopSheetSelectorsIDs.SECURITY_ALERT_BANNER, ); } - get securityAlertResponseFailedBanner(): DetoxElement { + get securityAlertResponseFailedBanner(): EncapsulatedElementType { return Matchers.getElementByID( ConfirmationTopSheetSelectorsIDs.SECURITY_ALERT_RESPONSE_FAILED_BANNER, ); diff --git a/tests/page-objects/Confirmation/GasFeeTokenModal.ts b/tests/page-objects/Confirmation/GasFeeTokenModal.ts index f994d43fc70..a09a83267c9 100644 --- a/tests/page-objects/Confirmation/GasFeeTokenModal.ts +++ b/tests/page-objects/Confirmation/GasFeeTokenModal.ts @@ -1,9 +1,14 @@ -import { Assertions, Gestures, Matchers } from '../../framework'; +import { + Assertions, + Gestures, + Matchers, + EncapsulatedElementType, +} from '../../framework'; import { GasFeeTokenModalSelectorsText } from '../../../app/components/Views/confirmations/ConfirmationView.testIds'; class GasFeeTokenModal { - getTokenItem(symbol: string): DetoxElement { + getTokenItem(symbol: string): EncapsulatedElementType { return Matchers.getElementByID( `${GasFeeTokenModalSelectorsText.GAS_FEE_TOKEN_ITEM}-${symbol}`, ); @@ -16,27 +21,27 @@ class GasFeeTokenModal { } async checkAmountToken(symbol: string, amount: string): Promise { - const amountElement = await Matchers.getElementByID( + const amountElement = (await Matchers.getElementByID( `${GasFeeTokenModalSelectorsText.GAS_FEE_TOKEN_AMOUNT}-${symbol}`, - ); + )) as Detox.IndexableNativeElement; const amountElementAttributes = await amountElement.getAttributes(); const amountElementLabel = this.elementSafe(amountElementAttributes); await Assertions.checkIfTextMatches(amountElementLabel, amount); } async checkBalance(symbol: string, balance: string): Promise { - const balanceElement = await Matchers.getElementByID( + const balanceElement = (await Matchers.getElementByID( `${GasFeeTokenModalSelectorsText.GAS_FEE_TOKEN_BALANCE}-${symbol}`, - ); + )) as Detox.IndexableNativeElement; const balanceElementAttributes = await balanceElement.getAttributes(); const balanceElementLabel = this.elementSafe(balanceElementAttributes); await Assertions.checkIfTextMatches(balanceElementLabel, balance); } async checkAmountFiat(symbol: string, amountFiat: string): Promise { - const amountFiatElement = await Matchers.getElementByID( + const amountFiatElement = (await Matchers.getElementByID( `${GasFeeTokenModalSelectorsText.GAS_FEE_TOKEN_AMOUNT_FIAT}-${symbol}`, - ); + )) as Detox.IndexableNativeElement; await Assertions.expectElementToBeVisible(amountFiatElement, { description: `Amount fiat for ${symbol} is visible`, diff --git a/tests/page-objects/Confirmation/TokenApproveConfirmation.ts b/tests/page-objects/Confirmation/TokenApproveConfirmation.ts index fcddb923f4d..7e00fb517a2 100644 --- a/tests/page-objects/Confirmation/TokenApproveConfirmation.ts +++ b/tests/page-objects/Confirmation/TokenApproveConfirmation.ts @@ -1,24 +1,25 @@ import { ApproveComponentIDs } from '../../../app/components/Views/confirmations/ConfirmationView.testIds'; import Matchers from '../../framework/Matchers'; import Gestures from '../../framework/Gestures'; +import { EncapsulatedElementType } from '../../framework'; // This components are used to check the approve confirmation specific components in the confirmation modal class TokenApproveConfirmation { - get SpendingCapValue(): DetoxElement { + get SpendingCapValue(): EncapsulatedElementType { return Matchers.getElementByID(ApproveComponentIDs.SPENDING_CAP_VALUE); } - get EditSpendingCapButton(): DetoxElement { + get EditSpendingCapButton(): EncapsulatedElementType { return Matchers.getElementByID( ApproveComponentIDs.EDIT_SPENDING_CAP_BUTTON, ); } - get EditSpendingCapInput(): DetoxElement { + get EditSpendingCapInput(): EncapsulatedElementType { return Matchers.getElementByID(ApproveComponentIDs.EDIT_SPENDING_CAP_INPUT); } - get EditSpendingCapSaveButton(): DetoxElement { + get EditSpendingCapSaveButton(): EncapsulatedElementType { return Matchers.getElementByID( ApproveComponentIDs.EDIT_SPENDING_CAP_SAVE_BUTTON, ); diff --git a/tests/page-objects/Earn/EarnLendingView.ts b/tests/page-objects/Earn/EarnLendingView.ts index 6c2c41ad717..538ed314904 100644 --- a/tests/page-objects/Earn/EarnLendingView.ts +++ b/tests/page-objects/Earn/EarnLendingView.ts @@ -6,61 +6,62 @@ import { EarnLendingViewSelectorsIDs, EarnLendingViewSelectorsText, } from '../../selectors/Earn/EarnLendingView.selectors'; +import { EncapsulatedElementType } from '../../framework'; class EarnLendingView { - get withdrawButton(): DetoxElement { + get withdrawButton(): EncapsulatedElementType { return Matchers.getElementByID(EarnLendingViewSelectorsIDs.WITHDRAW_BUTTON); } - get depositButton(): DetoxElement { + get depositButton(): EncapsulatedElementType { return Matchers.getElementByID(EarnLendingViewSelectorsIDs.DEPOSIT_BUTTON); } - get confirmationFooter(): DetoxElement { + get confirmationFooter(): EncapsulatedElementType { return Matchers.getElementByID( EarnLendingViewSelectorsIDs.CONFIRMATION_FOOTER, ); } - get confirmButton(): DetoxElement { + get confirmButton(): EncapsulatedElementType { return Matchers.getElementByID(EarnLendingViewSelectorsIDs.CONFIRM_BUTTON); } - get cancelButton(): DetoxElement { + get cancelButton(): EncapsulatedElementType { return Matchers.getElementByID(EarnLendingViewSelectorsIDs.CANCEL_BUTTON); } - get depositInfoSection(): DetoxElement { + get depositInfoSection(): EncapsulatedElementType { return Matchers.getElementByID( EarnLendingViewSelectorsIDs.DEPOSIT_INFO_SECTION, ); } - get depositReceiveSection(): DetoxElement { + get depositReceiveSection(): EncapsulatedElementType { return Matchers.getElementByID( EarnLendingViewSelectorsIDs.DEPOSIT_RECEIVE_SECTION, ); } - get progressBar(): DetoxElement { + get progressBar(): EncapsulatedElementType { return Matchers.getElementByID(EarnLendingViewSelectorsIDs.PROGRESS_BAR); } - get supplyTitle(): DetoxElement { + get supplyTitle(): EncapsulatedElementType { return Matchers.getElementByText(EarnLendingViewSelectorsText.SUPPLY); } - get reviewButton(): DetoxElement { + get reviewButton(): EncapsulatedElementType { return Matchers.getElementByID(EarnLendingViewSelectorsIDs.REVIEW_BUTTON); } - get withdrawalTimeLabel(): DetoxElement { + get withdrawalTimeLabel(): EncapsulatedElementType { return Matchers.getElementByText( EarnLendingViewSelectorsText.WITHDRAWAL_TIME, ); } - get confirmButtonByLabel(): DetoxElement { + get confirmButtonByLabel(): EncapsulatedElementType { return Matchers.getElementByText(EarnLendingViewSelectorsText.CONFIRM); } diff --git a/tests/page-objects/ErrorBoundaryView/ErrorBoundaryView.ts b/tests/page-objects/ErrorBoundaryView/ErrorBoundaryView.ts index a2b320e03d3..6701b87e13c 100644 --- a/tests/page-objects/ErrorBoundaryView/ErrorBoundaryView.ts +++ b/tests/page-objects/ErrorBoundaryView/ErrorBoundaryView.ts @@ -1,13 +1,14 @@ import Matchers from '../../framework/Matchers'; import Gestures from '../../framework/Gestures'; import { ErrorBoundarySelectorsText } from '../../selectors/ErrorBoundary/ErrorBoundaryView.selectors'; +import { EncapsulatedElementType } from '../../framework'; class ErrorBoundaryView { - get title(): DetoxElement { + get title(): EncapsulatedElementType { return Matchers.getElementByText(ErrorBoundarySelectorsText.TITLE); } - get srpLinkText(): DetoxElement { + get srpLinkText(): EncapsulatedElementType { return Matchers.getElementByText( ErrorBoundarySelectorsText.SAVE_YOUR_SRP_TEXT, ); diff --git a/tests/page-objects/MultichainAccounts/AccountDetails.ts b/tests/page-objects/MultichainAccounts/AccountDetails.ts index f650caf8763..aa14ac55508 100644 --- a/tests/page-objects/MultichainAccounts/AccountDetails.ts +++ b/tests/page-objects/MultichainAccounts/AccountDetails.ts @@ -2,53 +2,54 @@ import Matchers from '../../framework/Matchers'; import Gestures from '../../framework/Gestures'; import { AccountDetailsIds } from '../../../app/components/Views/MultichainAccounts/AccountDetails.testIds'; import { ExportCredentialsIds } from '../../../app/components/Views/MultichainAccounts/AccountDetails/ExportCredentials.testIds'; +import { EncapsulatedElementType } from '../../framework'; class AccountDetails { - get container(): DetoxElement { + get container(): EncapsulatedElementType { return Matchers.getElementByID(AccountDetailsIds.ACCOUNT_DETAILS_CONTAINER); } - get shareAddress(): DetoxElement { + get shareAddress(): EncapsulatedElementType { return Matchers.getElementByID(AccountDetailsIds.ACCOUNT_ADDRESS_LINK); } - get editAccountName(): DetoxElement { + get editAccountName(): EncapsulatedElementType { return Matchers.getElementByID(AccountDetailsIds.ACCOUNT_NAME_LINK); } - get editWalletName(): DetoxElement { + get editWalletName(): EncapsulatedElementType { return Matchers.getElementByID(AccountDetailsIds.WALLET_NAME_LINK); } - get networksLink(): DetoxElement { + get networksLink(): EncapsulatedElementType { return Matchers.getElementByID(AccountDetailsIds.NETWORKS_LINK); } - get backButton(): DetoxElement { + get backButton(): EncapsulatedElementType { return Matchers.getElementByID(AccountDetailsIds.BACK_BUTTON); } - get deleteAccountLink(): DetoxElement { + get deleteAccountLink(): EncapsulatedElementType { return Matchers.getElementByID(AccountDetailsIds.REMOVE_ACCOUNT_BUTTON); } - get accountSrpLink(): DetoxElement { + get accountSrpLink(): EncapsulatedElementType { return Matchers.getElementByID( AccountDetailsIds.SECRET_RECOVERY_PHRASE_LINK, ); } - get exportPrivateKeyButton(): DetoxElement { + get exportPrivateKeyButton(): EncapsulatedElementType { return Matchers.getElementByID( ExportCredentialsIds.EXPORT_PRIVATE_KEY_BUTTON, ); } - get privateKeysLink(): DetoxElement { + get privateKeysLink(): EncapsulatedElementType { return Matchers.getElementByID(AccountDetailsIds.PRIVATE_KEYS_LINK); } - get exportSrpButton(): DetoxElement { + get exportSrpButton(): EncapsulatedElementType { return Matchers.getElementByID(ExportCredentialsIds.EXPORT_SRP_BUTTON); } diff --git a/tests/page-objects/MultichainAccounts/AddressList.ts b/tests/page-objects/MultichainAccounts/AddressList.ts index 20d82481856..626f177fb73 100644 --- a/tests/page-objects/MultichainAccounts/AddressList.ts +++ b/tests/page-objects/MultichainAccounts/AddressList.ts @@ -1,9 +1,10 @@ import Matchers from '../../framework/Matchers'; import Gestures from '../../framework/Gestures'; import { AddressListIds } from '../../../app/components/Views/MultichainAccounts/AddressList/AddressList.testIds'; +import { EncapsulatedElementType } from '../../framework'; class AddressList { - get backButton(): DetoxElement { + get backButton(): EncapsulatedElementType { return Matchers.getElementByID(AddressListIds.GO_BACK); } diff --git a/tests/page-objects/MultichainAccounts/DeleteAccount.ts b/tests/page-objects/MultichainAccounts/DeleteAccount.ts index 85cfdca07e2..f3b6f8a717d 100644 --- a/tests/page-objects/MultichainAccounts/DeleteAccount.ts +++ b/tests/page-objects/MultichainAccounts/DeleteAccount.ts @@ -1,15 +1,16 @@ import Matchers from '../../framework/Matchers'; import Gestures from '../../framework/Gestures'; import { MultichainDeleteAccountSelectors } from '../../../app/components/Views/MultichainAccounts/sheets/DeleteAccount/DeleteAccount.testIds'; +import { EncapsulatedElementType } from '../../framework'; class DeleteAccount { - get container(): DetoxElement { + get container(): EncapsulatedElementType { return Matchers.getElementByID( MultichainDeleteAccountSelectors.DELETE_ACCOUNT_CONTAINER, ); } - get deleteAccountButton(): DetoxElement { + get deleteAccountButton(): EncapsulatedElementType { return Matchers.getElementByID( MultichainDeleteAccountSelectors.DELETE_ACCOUNT_REMOVE_BUTTON, ); diff --git a/tests/page-objects/MultichainAccounts/EditAccountName.ts b/tests/page-objects/MultichainAccounts/EditAccountName.ts index ecd0c8b11b7..ff93b249aa1 100644 --- a/tests/page-objects/MultichainAccounts/EditAccountName.ts +++ b/tests/page-objects/MultichainAccounts/EditAccountName.ts @@ -1,19 +1,20 @@ import Matchers from '../../framework/Matchers'; import Gestures from '../../framework/Gestures'; import { EditAccountNameIds } from '../../../app/components/Views/MultichainAccounts/sheets/EditAccountName.testIds'; +import { EncapsulatedElementType } from '../../framework'; class EditAccountName { - get container(): DetoxElement { + get container(): EncapsulatedElementType { return Matchers.getElementByID( EditAccountNameIds.EDIT_ACCOUNT_NAME_CONTAINER, ); } - get accountNameInput(): DetoxElement { + get accountNameInput(): EncapsulatedElementType { return Matchers.getElementByID(EditAccountNameIds.ACCOUNT_NAME_INPUT); } - get saveButton(): DetoxElement { + get saveButton(): EncapsulatedElementType { return Matchers.getElementByID(EditAccountNameIds.SAVE_BUTTON); } diff --git a/tests/page-objects/MultichainAccounts/ExportCredentials.ts b/tests/page-objects/MultichainAccounts/ExportCredentials.ts index fc17d7692cb..1a3c4df057f 100644 --- a/tests/page-objects/MultichainAccounts/ExportCredentials.ts +++ b/tests/page-objects/MultichainAccounts/ExportCredentials.ts @@ -2,39 +2,40 @@ import Matchers from '../../framework/Matchers'; import Gestures from '../../framework/Gestures'; import { ExportCredentialsIds } from '../../../app/components/Views/MultichainAccounts/AccountDetails/ExportCredentials.testIds'; import { RevealSeedViewSelectorsIDs } from '../../../app/components/Views/RevealPrivateCredential/RevealSeedView.testIds'; +import { EncapsulatedElementType } from '../../framework'; class ExportCredentials { - get srpInfoContainer(): DetoxElement { + get srpInfoContainer(): EncapsulatedElementType { return Matchers.getElementByID(ExportCredentialsIds.CONTAINER); } - get revealContainer(): DetoxElement { + get revealContainer(): EncapsulatedElementType { return Matchers.getElementByID( RevealSeedViewSelectorsIDs.REVEAL_CREDENTIAL_CONTAINER_ID, ); } - get exportPrivateKeyButton(): DetoxElement { + get exportPrivateKeyButton(): EncapsulatedElementType { return Matchers.getElementByID( ExportCredentialsIds.EXPORT_PRIVATE_KEY_BUTTON, ); } - get exportSrpButton(): DetoxElement { + get exportSrpButton(): EncapsulatedElementType { return Matchers.getElementByID(ExportCredentialsIds.EXPORT_SRP_BUTTON); } - get passwordInput(): DetoxElement { + get passwordInput(): EncapsulatedElementType { return Matchers.getElementByID( RevealSeedViewSelectorsIDs.PASSWORD_INPUT_BOX_ID, ); } - get nextButton(): DetoxElement { + get nextButton(): EncapsulatedElementType { return Matchers.getElementByID(ExportCredentialsIds.NEXT_BUTTON); } - get learnMoreButton(): DetoxElement { + get learnMoreButton(): EncapsulatedElementType { return Matchers.getElementByID(ExportCredentialsIds.LEARN_MORE_BUTTON); } diff --git a/tests/page-objects/MultichainAccounts/PrivateKeyList.ts b/tests/page-objects/MultichainAccounts/PrivateKeyList.ts index 8e9679cf465..94e5c0f1cca 100644 --- a/tests/page-objects/MultichainAccounts/PrivateKeyList.ts +++ b/tests/page-objects/MultichainAccounts/PrivateKeyList.ts @@ -4,27 +4,28 @@ import { PrivateKeyListIds, PrivateKeyListSelectorsText, } from '../../../app/components/Views/MultichainAccounts/PrivateKeyList/PrivateKeyList.testIds'; +import { EncapsulatedElementType } from '../../framework'; class PrivateKeyList { - get passwordInput(): DetoxElement { + get passwordInput(): EncapsulatedElementType { return Matchers.getElementByID(PrivateKeyListIds.PASSWORD_INPUT); } - get continueButton(): DetoxElement { + get continueButton(): EncapsulatedElementType { return Matchers.getElementByID(PrivateKeyListIds.CONTINUE_BUTTON); } - get copyToClipboard(): DetoxElement { + get copyToClipboard(): EncapsulatedElementType { return Matchers.getElementByID(PrivateKeyListIds.COPY_TO_CLIPBOARD_BUTTON); } - get privateKeyCopiedLabel(): DetoxElement { + get privateKeyCopiedLabel(): EncapsulatedElementType { return Matchers.getElementByText( PrivateKeyListSelectorsText.PRIVATE_KEY_COPIED, ); } - get wrongPasswordLabel(): DetoxElement { + get wrongPasswordLabel(): EncapsulatedElementType { return Matchers.getElementByText( PrivateKeyListSelectorsText.WRONG_PASSWORD_ERROR, ); diff --git a/tests/page-objects/MultichainAccounts/ShareAddress.ts b/tests/page-objects/MultichainAccounts/ShareAddress.ts index c4eb3da3ff6..0364325e441 100644 --- a/tests/page-objects/MultichainAccounts/ShareAddress.ts +++ b/tests/page-objects/MultichainAccounts/ShareAddress.ts @@ -1,27 +1,28 @@ import Matchers from '../../framework/Matchers'; import Gestures from '../../framework/Gestures'; import { ShareAddressIds } from '../../../app/components/Views/MultichainAccounts/sheets/ShareAddress/ShareAddress.testIds'; +import { EncapsulatedElementType } from '../../framework'; class ShareAddress { - get container(): DetoxElement { + get container(): EncapsulatedElementType { return Matchers.getElementByID(ShareAddressIds.SHARE_ADDRESS_CONTAINER); } - get qrCode(): DetoxElement { + get qrCode(): EncapsulatedElementType { return Matchers.getElementByID(ShareAddressIds.SHARE_ADDRESS_QR_CODE); } - get accountAddress(): DetoxElement { + get accountAddress(): EncapsulatedElementType { return Matchers.getElementByID( ShareAddressIds.SHARE_ADDRESS_ACCOUNT_ADDRESS, ); } - get copyButton(): DetoxElement { + get copyButton(): EncapsulatedElementType { return Matchers.getElementByID(ShareAddressIds.SHARE_ADDRESS_COPY_BUTTON); } - get viewOnExplorerButton(): DetoxElement { + get viewOnExplorerButton(): EncapsulatedElementType { return Matchers.getElementByID( ShareAddressIds.SHARE_ADDRESS_VIEW_ON_EXPLORER_BUTTON, ); diff --git a/tests/page-objects/MultichainAccounts/SmartAccount.ts b/tests/page-objects/MultichainAccounts/SmartAccount.ts index 4ec94510d22..5d5e8d9d9ca 100644 --- a/tests/page-objects/MultichainAccounts/SmartAccount.ts +++ b/tests/page-objects/MultichainAccounts/SmartAccount.ts @@ -1,13 +1,14 @@ import Matchers from '../../framework/Matchers'; import Gestures from '../../framework/Gestures'; import { SmartAccountIds } from '../../../app/components/Views/MultichainAccounts/SmartAccount.testIds'; +import { EncapsulatedElementType } from '../../framework'; class SmartAccount { - get container(): DetoxElement { + get container(): EncapsulatedElementType { return Matchers.getElementByID(SmartAccountIds.SMART_ACCOUNT_CONTAINER); } - get smartAccountSwitch(): DetoxElement { + get smartAccountSwitch(): EncapsulatedElementType { return Matchers.getElementByID(SmartAccountIds.SMART_ACCOUNT_SWITCH); } diff --git a/tests/page-objects/MultichainAccounts/WalletDetails.ts b/tests/page-objects/MultichainAccounts/WalletDetails.ts index 07767b1621f..0be66fb387e 100644 --- a/tests/page-objects/MultichainAccounts/WalletDetails.ts +++ b/tests/page-objects/MultichainAccounts/WalletDetails.ts @@ -1,17 +1,18 @@ import Matchers from '../../framework/Matchers'; import Gestures from '../../framework/Gestures'; import { WalletDetailsIds } from '../../../app/components/Views/MultichainAccounts/WalletDetails/WalletDetails.testIds'; +import { EncapsulatedElementType } from '../../framework'; class WalletDetails { - get container(): DetoxElement { + get container(): EncapsulatedElementType { return Matchers.getElementByID(WalletDetailsIds.WALLET_DETAILS_CONTAINER); } - get createAccountLink(): DetoxElement { + get createAccountLink(): EncapsulatedElementType { return Matchers.getElementByID(WalletDetailsIds.ADD_ACCOUNT_BUTTON); } - get srpButton(): DetoxElement { + get srpButton(): EncapsulatedElementType { return Matchers.getElementByID(WalletDetailsIds.REVEAL_SRP_BUTTON); } diff --git a/tests/page-objects/Network/NetworkAddedBottomSheet.ts b/tests/page-objects/Network/NetworkAddedBottomSheet.ts index 31025a4d1cf..d4f0bb64310 100644 --- a/tests/page-objects/Network/NetworkAddedBottomSheet.ts +++ b/tests/page-objects/Network/NetworkAddedBottomSheet.ts @@ -4,21 +4,22 @@ import { } from '../../../app/components/UI/NetworkModal/NetworkAddedBottomSheet.testIds'; import Matchers from '../../framework/Matchers'; import Gestures from '../../framework/Gestures'; +import { EncapsulatedElementType } from '../../framework'; class NetworkAddedBottomSheet { - get switchNetwork(): DetoxElement { + get switchNetwork(): EncapsulatedElementType { return Matchers.getElementByText( NetworkAddedBottomSheetSelectorsText.SWITCH_NETWORK, ); } - get switchNetworkButton(): DetoxElement { + get switchNetworkButton(): EncapsulatedElementType { return Matchers.getElementByID( NetworkAddedBottomSheetSelectorsIDs.SWITCH_NETWORK_BUTTON, ); } - get closeNetworkButton(): DetoxElement { + get closeNetworkButton(): EncapsulatedElementType { return Matchers.getElementByID( NetworkAddedBottomSheetSelectorsIDs.CLOSE_NETWORK_BUTTON, ); diff --git a/tests/page-objects/Network/NetworkApprovalBottomSheet.ts b/tests/page-objects/Network/NetworkApprovalBottomSheet.ts index a5cbb427b3a..b567e4066fa 100644 --- a/tests/page-objects/Network/NetworkApprovalBottomSheet.ts +++ b/tests/page-objects/Network/NetworkApprovalBottomSheet.ts @@ -1,26 +1,27 @@ import { NetworkApprovalBottomSheetSelectorsIDs } from '../../../app/components/UI/NetworkModal/NetworkApprovalBottomSheet.testIds'; import Matchers from '../../framework/Matchers'; import Gestures from '../../framework/Gestures'; +import { EncapsulatedElementType } from '../../framework'; class NetworkApprovalBottomSheet { - get container(): DetoxElement { + get container(): EncapsulatedElementType { return Matchers.getElementByID( NetworkApprovalBottomSheetSelectorsIDs.CONTAINER, ); } - get approvedButton(): DetoxElement { + get approvedButton(): EncapsulatedElementType { return Matchers.getElementByID( NetworkApprovalBottomSheetSelectorsIDs.APPROVE_BUTTON, ); } - get cancelButton(): DetoxElement { + get cancelButton(): EncapsulatedElementType { return Matchers.getElementByID( NetworkApprovalBottomSheetSelectorsIDs.CANCEL_BUTTON, ); } - get displayName(): DetoxElement { + get displayName(): EncapsulatedElementType { return Matchers.getElementByID( NetworkApprovalBottomSheetSelectorsIDs.DISPLAY_NAME, ); diff --git a/tests/page-objects/Network/NetworkEducationModal.ts b/tests/page-objects/Network/NetworkEducationModal.ts index aa976501872..b7d585ada86 100644 --- a/tests/page-objects/Network/NetworkEducationModal.ts +++ b/tests/page-objects/Network/NetworkEducationModal.ts @@ -54,7 +54,7 @@ class NetworkEducationModal { }); } - get addToken(): DetoxElement { + get addToken(): EncapsulatedElementType { return Matchers.getElementByText( NetworkEducationModalSelectorsText.ADD_TOKEN, ); diff --git a/tests/page-objects/Network/NetworkListModal.ts b/tests/page-objects/Network/NetworkListModal.ts index 8115cecc9f3..56a5b897988 100644 --- a/tests/page-objects/Network/NetworkListModal.ts +++ b/tests/page-objects/Network/NetworkListModal.ts @@ -6,51 +6,52 @@ import { NetworksViewSelectorsIDs } from '../../../app/components/Views/Settings import Matchers from '../../framework/Matchers'; import Gestures from '../../framework/Gestures'; import { NETWORK_MULTI_SELECTOR_TEST_IDS } from '../../../app/components/UI/NetworkMultiSelector/NetworkMultiSelector.constants'; +import { EncapsulatedElementType } from '../../framework'; class NetworkListModal { - get networkScroll(): DetoxElement { + get networkScroll(): EncapsulatedElementType { return Matchers.getElementByID(NetworkListModalSelectorsIDs.SCROLL); } - get closeIcon(): DetoxElement { + get closeIcon(): EncapsulatedElementType { return Matchers.getElementByID(NetworksViewSelectorsIDs.CLOSE_ICON); } - get deleteNetworkButton(): DetoxElement { + get deleteNetworkButton(): EncapsulatedElementType { return Matchers.getElementByText( NetworkListModalSelectorsText.DELETE_NETWORK, ); } - get addPopularNetworkButton(): DetoxElement { + get addPopularNetworkButton(): EncapsulatedElementType { return Matchers.getElementByText( NetworkListModalSelectorsText.ADD_POPULAR_NETWORK_BUTTON, ); } - get networkSearchInput(): DetoxElement { + get networkSearchInput(): EncapsulatedElementType { return Matchers.getElementByID( NetworksViewSelectorsIDs.SEARCH_NETWORK_INPUT_BOX_ID, ); } - get selectNetwork(): DetoxElement { + get selectNetwork(): EncapsulatedElementType { return Matchers.getElementByText( NetworkListModalSelectorsText.SELECT_NETWORK, ); } - get testNetToggle(): DetoxElement { + get testNetToggle(): EncapsulatedElementType { return Matchers.getElementByID( NetworkListModalSelectorsIDs.TEST_NET_TOGGLE, ); } - get deleteButton(): DetoxElement { + get deleteButton(): EncapsulatedElementType { return Matchers.getElementByID('delete-network-button'); } - get popularNetworksContainer(): DetoxElement { + get popularNetworksContainer(): EncapsulatedElementType { return Matchers.getElementByID( NETWORK_MULTI_SELECTOR_TEST_IDS.POPULAR_NETWORKS_CONTAINER, ); @@ -59,7 +60,7 @@ class NetworkListModal { async getCustomNetwork( network: string, custom = false, - ): Promise { + ): Promise { if (device.getPlatform() === 'android' || !custom) { return Matchers.getElementByText(network); } diff --git a/tests/page-objects/Network/NetworkNonPemittedBottomSheet.ts b/tests/page-objects/Network/NetworkNonPemittedBottomSheet.ts index 50e466a4003..201875b45cf 100644 --- a/tests/page-objects/Network/NetworkNonPemittedBottomSheet.ts +++ b/tests/page-objects/Network/NetworkNonPemittedBottomSheet.ts @@ -4,51 +4,52 @@ import { NetworkNonPemittedBottomSheetSelectorsIDs, NetworkNonPemittedBottomSheetSelectorsText, } from '../../../app/components/Views/NetworkConnect/NetworkNonPemittedBottomSheet.testIds'; +import { EncapsulatedElementType } from '../../framework'; class NetworkNonPemittedBottomSheet { - get addThisNetworkTitle(): DetoxElement { + get addThisNetworkTitle(): EncapsulatedElementType { return Matchers.getElementByText( NetworkNonPemittedBottomSheetSelectorsText.ADD_THIS_NETWORK_TITLE, ); } - get sepoliaNetworkName(): DetoxElement { + get sepoliaNetworkName(): EncapsulatedElementType { return Matchers.getElementByText( NetworkNonPemittedBottomSheetSelectorsText.SEPOLIA_NETWORK_NAME, ); } - get ethereumMainNetNetworkName(): DetoxElement { + get ethereumMainNetNetworkName(): EncapsulatedElementType { return Matchers.getElementByText( NetworkNonPemittedBottomSheetSelectorsText.ETHEREUM_MAIN_NET_NETWORK_NAME, ); } - get addThisNetworkButton(): DetoxElement { + get addThisNetworkButton(): EncapsulatedElementType { return Matchers.getElementByID( NetworkNonPemittedBottomSheetSelectorsIDs.ADD_THIS_NETWORK_BUTTON, ); } - get lineaSepoliaNetworkName(): DetoxElement { + get lineaSepoliaNetworkName(): EncapsulatedElementType { return Matchers.getElementByText( NetworkNonPemittedBottomSheetSelectorsText.LINEA_SEPOLIA_NETWORK_NAME, ); } - get elysiumTestnetNetworkName(): DetoxElement { + get elysiumTestnetNetworkName(): EncapsulatedElementType { return Matchers.getElementByText( NetworkNonPemittedBottomSheetSelectorsText.ELYSIUM_TESTNET_NETWORK_NAME, ); } - get chooseFromPermittedNetworksButton(): DetoxElement { + get chooseFromPermittedNetworksButton(): EncapsulatedElementType { return Matchers.getElementByID( NetworkNonPemittedBottomSheetSelectorsIDs.CHOOSE_FROM_PERMITTED_NETWORKS_BUTTON, ); } - get editPermissionsButton(): DetoxElement { + get editPermissionsButton(): EncapsulatedElementType { return Matchers.getElementByID( NetworkNonPemittedBottomSheetSelectorsIDs.EDIT_PERMISSIONS_BUTTON, ); diff --git a/tests/page-objects/Onboarding/CreatePasswordView.ts b/tests/page-objects/Onboarding/CreatePasswordView.ts index 99c6445d88b..611c5fb642e 100644 --- a/tests/page-objects/Onboarding/CreatePasswordView.ts +++ b/tests/page-objects/Onboarding/CreatePasswordView.ts @@ -17,7 +17,7 @@ import { PlatformDetector } from '../../framework/PlatformLocator'; import { ImportFromSeedSelectorsIDs } from '../../../app/components/Views/ImportFromSecretRecoveryPhrase/ImportFromSeed.testIds'; class CreatePasswordView { - get container(): DetoxElement { + get container(): EncapsulatedElementType { return Matchers.getElementByID(ChoosePasswordSelectorsIDs.CONTAINER_ID); } @@ -140,7 +140,7 @@ class CreatePasswordView { }); } - get iUnderstandCheckboxNewWallet(): DetoxElement { + get iUnderstandCheckboxNewWallet(): EncapsulatedElementType { return Matchers.getElementByID( ChoosePasswordSelectorsIDs.I_UNDERSTAND_CHECKBOX_ID, ); @@ -166,7 +166,7 @@ class CreatePasswordView { }); } - get passwordError(): DetoxElement { + get passwordError(): EncapsulatedElementType { return Matchers.getElementByText(enContent.import_from_seed.password_error); } diff --git a/tests/page-objects/Onboarding/ExperienceEnhancerBottomSheet.ts b/tests/page-objects/Onboarding/ExperienceEnhancerBottomSheet.ts index 28c6fe48906..3a7192a6d7a 100644 --- a/tests/page-objects/Onboarding/ExperienceEnhancerBottomSheet.ts +++ b/tests/page-objects/Onboarding/ExperienceEnhancerBottomSheet.ts @@ -13,7 +13,7 @@ import PlaywrightMatchers from '../../framework/PlaywrightMatchers'; import PlaywrightGestures from '../../framework/PlaywrightGestures'; class ExperienceEnhancerBottomSheet { - get container(): DetoxElement { + get container(): EncapsulatedElementType { return Matchers.getElementByID( ExperienceEnhancerBottomSheetSelectorsIDs.BOTTOM_SHEET, ); diff --git a/tests/page-objects/Onboarding/ImportWalletView.ts b/tests/page-objects/Onboarding/ImportWalletView.ts index 26e106187fd..9fa77492328 100644 --- a/tests/page-objects/Onboarding/ImportWalletView.ts +++ b/tests/page-objects/Onboarding/ImportWalletView.ts @@ -17,7 +17,7 @@ import UnifiedGestures from '../../framework/UnifiedGestures'; import PlaywrightGestures from '../../framework/PlaywrightGestures'; class ImportWalletView { - get container(): DetoxElement { + get container(): EncapsulatedElementType { return Matchers.getElementByID(ImportFromSeedSelectorsIDs.CONTAINER_ID); } @@ -35,13 +35,13 @@ class ImportWalletView { }); } - get newPasswordInput(): DetoxElement { + get newPasswordInput(): EncapsulatedElementType { return Matchers.getElementByID( ChoosePasswordSelectorsIDs.NEW_PASSWORD_INPUT_ID, ); } - get confirmPasswordInput(): DetoxElement { + get confirmPasswordInput(): EncapsulatedElementType { return Matchers.getElementByID( ChoosePasswordSelectorsIDs.CONFIRM_PASSWORD_INPUT_ID, ); diff --git a/tests/page-objects/Onboarding/ManualBackupStep1View.ts b/tests/page-objects/Onboarding/ManualBackupStep1View.ts index c0a2c3a3c17..e98851006f6 100644 --- a/tests/page-objects/Onboarding/ManualBackupStep1View.ts +++ b/tests/page-objects/Onboarding/ManualBackupStep1View.ts @@ -1,15 +1,16 @@ import { ManualBackUpStepsSelectorsIDs } from '../../../app/components/Views/ManualBackupStep1/ManualBackUpSteps.testIds'; import Matchers from '../../framework/Matchers'; import Gestures from '../../framework/Gestures'; +import { EncapsulatedElementType } from '../../framework'; class ManualBackupStep1View { - get container(): DetoxElement { + get container(): EncapsulatedElementType { return Matchers.getElementByID( ManualBackUpStepsSelectorsIDs.STEP_1_CONTAINER, ); } - get remindMeLaterButton(): DetoxElement { + get remindMeLaterButton(): EncapsulatedElementType { return Matchers.getElementByID( ManualBackUpStepsSelectorsIDs.REMIND_ME_LATER_BUTTON, ); diff --git a/tests/page-objects/Onboarding/MetaMetricsOptInView.ts b/tests/page-objects/Onboarding/MetaMetricsOptInView.ts index 75f02be8f3c..26aad187ac7 100644 --- a/tests/page-objects/Onboarding/MetaMetricsOptInView.ts +++ b/tests/page-objects/Onboarding/MetaMetricsOptInView.ts @@ -15,7 +15,7 @@ import UnifiedGestures from '../../framework/UnifiedGestures'; import { PlaywrightGestures } from '../../framework'; class MetaMetricsOptIn { - get container(): DetoxElement { + get container(): EncapsulatedElementType { return Matchers.getElementByID( MetaMetricsOptInSelectorsIDs.METAMETRICS_OPT_IN_CONTAINER_ID, ); @@ -37,7 +37,7 @@ class MetaMetricsOptIn { }); } - get optInMetricsContent(): DetoxElement { + get optInMetricsContent(): EncapsulatedElementType { return Matchers.getElementByID( MetaMetricsOptInSelectorsIDs.OPTIN_METRICS_PRIVACY_POLICY_DESCRIPTION_CONTENT_1_ID, ); @@ -59,7 +59,7 @@ class MetaMetricsOptIn { }); } - get metricsCheckbox(): DetoxElement { + get metricsCheckbox(): EncapsulatedElementType { return Matchers.getElementByID( MetaMetricsOptInSelectorsIDs.OPTIN_METRICS_METRICS_CHECKBOX, ); diff --git a/tests/page-objects/Onboarding/OnboardingCarouselView.ts b/tests/page-objects/Onboarding/OnboardingCarouselView.ts index 24a6ca06aa9..e6158d79797 100644 --- a/tests/page-objects/Onboarding/OnboardingCarouselView.ts +++ b/tests/page-objects/Onboarding/OnboardingCarouselView.ts @@ -4,43 +4,44 @@ import { } from '../../selectors/Onboarding/OnboardingCarousel.selectors'; import Matchers from '../../framework/Matchers'; import Gestures from '../../framework/Gestures'; +import { EncapsulatedElementType } from '../../framework'; class OnboardingCarouselView { - get container(): DetoxElement { + get container(): EncapsulatedElementType { return Matchers.getElementByID( OnboardingCarouselSelectorIDs.CAROUSEL_CONTAINER_ID, ); } - get getStartedButton(): DetoxElement { + get getStartedButton(): EncapsulatedElementType { return Matchers.getElementByID( OnboardingCarouselSelectorIDs.GET_STARTED_BUTTON_ID, ); } - get titleOne(): DetoxElement { + get titleOne(): EncapsulatedElementType { return Matchers.getElementByText(OnboardingCarouselSelectorText.TITLE_ONE); } - get imageOne(): DetoxElement { + get imageOne(): EncapsulatedElementType { return Matchers.getElementByID(OnboardingCarouselSelectorIDs.ONE_IMAGE_ID); } - get titleTwo(): DetoxElement { + get titleTwo(): EncapsulatedElementType { return Matchers.getElementByText(OnboardingCarouselSelectorText.TITLE_TWO); } - get imageTwo(): DetoxElement { + get imageTwo(): EncapsulatedElementType { return Matchers.getElementByID(OnboardingCarouselSelectorIDs.TWO_IMAGE_ID); } - get titleThree(): DetoxElement { + get titleThree(): EncapsulatedElementType { return Matchers.getElementByText( OnboardingCarouselSelectorText.TITLE_THREE, ); } - get imageThree(): DetoxElement { + get imageThree(): EncapsulatedElementType { return Matchers.getElementByID( OnboardingCarouselSelectorIDs.THREE_IMAGE_ID, ); diff --git a/tests/page-objects/Onboarding/OnboardingSheet.ts b/tests/page-objects/Onboarding/OnboardingSheet.ts index 37bda110c76..a11cecb9c00 100644 --- a/tests/page-objects/Onboarding/OnboardingSheet.ts +++ b/tests/page-objects/Onboarding/OnboardingSheet.ts @@ -9,7 +9,7 @@ import PlaywrightMatchers from '../../framework/PlaywrightMatchers'; import UnifiedGestures from '../../framework/UnifiedGestures'; class OnboardingSheet { - get container(): DetoxElement { + get container(): EncapsulatedElementType { return Matchers.getElementByID(OnboardingSheetSelectorIDs.CONTAINER_ID); } diff --git a/tests/page-objects/Onboarding/OnboardingSuccessView.ts b/tests/page-objects/Onboarding/OnboardingSuccessView.ts index a7e50f12441..caa23aa77db 100644 --- a/tests/page-objects/Onboarding/OnboardingSuccessView.ts +++ b/tests/page-objects/Onboarding/OnboardingSuccessView.ts @@ -9,7 +9,7 @@ import PlaywrightMatchers from '../../framework/PlaywrightMatchers'; import UnifiedGestures from '../../framework/UnifiedGestures'; class OnboardingSuccessView { - get container(): DetoxElement { + get container(): EncapsulatedElementType { return Matchers.getElementByID(OnboardingSuccessSelectorIDs.CONTAINER_ID); } diff --git a/tests/page-objects/Onboarding/OnboardingView.ts b/tests/page-objects/Onboarding/OnboardingView.ts index 0ba1a293b5e..0f91bc5661b 100644 --- a/tests/page-objects/Onboarding/OnboardingView.ts +++ b/tests/page-objects/Onboarding/OnboardingView.ts @@ -15,7 +15,7 @@ import { } from '../../framework'; class OnboardingView { - get container(): DetoxElement { + get container(): EncapsulatedElementType { return Matchers.getElementByID(OnboardingSelectorIDs.CONTAINER_ID); } diff --git a/tests/page-objects/Onboarding/ProtectYourWalletModal.ts b/tests/page-objects/Onboarding/ProtectYourWalletModal.ts index 7994dabb61f..a93a2099d79 100644 --- a/tests/page-objects/Onboarding/ProtectYourWalletModal.ts +++ b/tests/page-objects/Onboarding/ProtectYourWalletModal.ts @@ -1,19 +1,20 @@ import { ProtectWalletModalSelectorsIDs } from '../../../app/components/UI/ProtectYourWalletModal/ProtectWalletModal.testIds'; import Matchers from '../../framework/Matchers'; import Gestures from '../../framework/Gestures'; +import { EncapsulatedElementType } from '../../framework'; class ProtectYourWalletModal { - get container(): DetoxElement { + get container(): EncapsulatedElementType { return Matchers.getElementByID(ProtectWalletModalSelectorsIDs.CONTAINER); } - get remindMeLaterButton(): DetoxElement { + get remindMeLaterButton(): EncapsulatedElementType { return Matchers.getElementByID( ProtectWalletModalSelectorsIDs.REMIND_ME_LATER_BUTTON, ); } - get collapseWalletModal(): DetoxElement { + get collapseWalletModal(): EncapsulatedElementType { return Matchers.getElementByID( ProtectWalletModalSelectorsIDs.COLLAPSED_WALLET_MODAL, ); diff --git a/tests/page-objects/Onboarding/ProtectYourWalletView.ts b/tests/page-objects/Onboarding/ProtectYourWalletView.ts index c477e1dceb6..070bfc004c7 100644 --- a/tests/page-objects/Onboarding/ProtectYourWalletView.ts +++ b/tests/page-objects/Onboarding/ProtectYourWalletView.ts @@ -13,7 +13,7 @@ import PlaywrightMatchers from '../../framework/PlaywrightMatchers'; import { PlatformDetector } from '../../framework/PlatformLocator'; class ProtectYourWalletView { - get container(): DetoxElement { + get container(): EncapsulatedElementType { return Matchers.getElementByID( ManualBackUpStepsSelectorsIDs.PROTECT_CONTAINER, ); diff --git a/tests/page-objects/Onboarding/WhatsNewModal.ts b/tests/page-objects/Onboarding/WhatsNewModal.ts index c2842772a27..ae5d729d8ab 100644 --- a/tests/page-objects/Onboarding/WhatsNewModal.ts +++ b/tests/page-objects/Onboarding/WhatsNewModal.ts @@ -1,13 +1,14 @@ import { WhatsNewModalSelectorsIDs } from '../../../app/components/UI/WhatsNewModal/WhatsNewModal.testIds'; import Matchers from '../../framework/Matchers'; import Gestures from '../../framework/Gestures'; +import { EncapsulatedElementType } from '../../framework'; class WhatsNewModal { - get container(): DetoxElement { + get container(): EncapsulatedElementType { return Matchers.getElementByID(WhatsNewModalSelectorsIDs.CONTAINER); } - get closeButton(): DetoxElement { + get closeButton(): EncapsulatedElementType { return Matchers.getElementByID(WhatsNewModalSelectorsIDs.CLOSE_BUTTON); } diff --git a/tests/page-objects/Perps/PerpsDepositProcessingView.ts b/tests/page-objects/Perps/PerpsDepositProcessingView.ts index e90d7e06a8d..59a8eee05e1 100644 --- a/tests/page-objects/Perps/PerpsDepositProcessingView.ts +++ b/tests/page-objects/Perps/PerpsDepositProcessingView.ts @@ -2,27 +2,28 @@ import { PerpsDepositProcessingViewSelectorsIDs } from '../../../app/components/ import Matchers from '../../framework/Matchers'; import Assertions from '../../framework/Assertions'; import Gestures from '../../framework/Gestures'; +import { EncapsulatedElementType } from '../../framework'; class PerpsDepositProcessingView { - get headerTitle(): DetoxElement { + get headerTitle(): EncapsulatedElementType { return Matchers.getElementByID( PerpsDepositProcessingViewSelectorsIDs.HEADER_TITLE, ); } - get statusTitle(): DetoxElement { + get statusTitle(): EncapsulatedElementType { return Matchers.getElementByID( PerpsDepositProcessingViewSelectorsIDs.STATUS_TITLE, ); } - get statusDescription(): DetoxElement { + get statusDescription(): EncapsulatedElementType { return Matchers.getElementByID( PerpsDepositProcessingViewSelectorsIDs.STATUS_DESCRIPTION, ); } - get viewBalanceButton(): DetoxElement { + get viewBalanceButton(): EncapsulatedElementType { return Matchers.getElementByID( PerpsDepositProcessingViewSelectorsIDs.VIEW_BALANCE_BUTTON, ); diff --git a/tests/page-objects/Perps/PerpsDepositView.ts b/tests/page-objects/Perps/PerpsDepositView.ts index 6f255d4703f..69fa8028b7d 100644 --- a/tests/page-objects/Perps/PerpsDepositView.ts +++ b/tests/page-objects/Perps/PerpsDepositView.ts @@ -21,7 +21,7 @@ const TIMEOUT = { class PerpsDepositView { // Custom deposit keypad container - get keypad(): DetoxElement { + get keypad(): EncapsulatedElementType { return Matchers.getElementByID('deposit-keyboard'); } @@ -63,7 +63,7 @@ class PerpsDepositView { // Add funds (confirm) button on review screen. Uses testID for reliability: // the confirmation screen shows at most one "Add funds" (ConfirmButton); // index 1 was failing when no second "Add funds" existed in the hierarchy. - get confirmButton(): DetoxElement { + get confirmButton(): EncapsulatedElementType { return Matchers.getElementByID('confirm-button'); } @@ -84,7 +84,7 @@ class PerpsDepositView { }); } - get usdcOption(): DetoxElement { + get usdcOption(): EncapsulatedElementType { return Matchers.getElementByText('USDC'); } diff --git a/tests/page-objects/Perps/PerpsHomeView.ts b/tests/page-objects/Perps/PerpsHomeView.ts index b602bf47fff..f0cf10ae358 100644 --- a/tests/page-objects/Perps/PerpsHomeView.ts +++ b/tests/page-objects/Perps/PerpsHomeView.ts @@ -3,13 +3,14 @@ import Gestures from '../../framework/Gestures'; import Matchers from '../../framework/Matchers'; import Utilities from '../../framework/Utilities'; import enContent from '../../../locales/languages/en.json'; +import { EncapsulatedElementType } from '../../framework'; class PerpsHomeView { - get exploreCrypto(): DetoxElement { + get exploreCrypto(): EncapsulatedElementType { return Matchers.getElementByText(enContent.perps.home.crypto); } - get backHome(): DetoxElement { + get backHome(): EncapsulatedElementType { return Matchers.getElementByID(PerpsHomeViewSelectorsIDs.BACK_HOME_BUTTON); } diff --git a/tests/page-objects/Perps/PerpsMarketDetailsView.ts b/tests/page-objects/Perps/PerpsMarketDetailsView.ts index 456e59d4560..d0969cbebfb 100644 --- a/tests/page-objects/Perps/PerpsMarketDetailsView.ts +++ b/tests/page-objects/Perps/PerpsMarketDetailsView.ts @@ -133,7 +133,7 @@ class PerpsMarketDetailsView { } // Scroll view - get scrollView(): DetoxElement { + get scrollView(): EncapsulatedElementType { return Matchers.getElementByID( PerpsMarketDetailsViewSelectorsIDs.SCROLL_VIEW, ); diff --git a/tests/page-objects/Perps/PerpsMarketListView.ts b/tests/page-objects/Perps/PerpsMarketListView.ts index 1516ddebdf7..3551700ddc2 100644 --- a/tests/page-objects/Perps/PerpsMarketListView.ts +++ b/tests/page-objects/Perps/PerpsMarketListView.ts @@ -34,7 +34,7 @@ class PerpsMarketListView { * HeaderCompactStandard back on explore market list (see PerpsMarketListView.tsx). * Navigates from the market list back to Perps portfolio home. */ - get headerBackButton(): DetoxElement { + get headerBackButton(): EncapsulatedElementType { return Matchers.getElementByID( `${PerpsMarketListViewSelectorsIDs.CLOSE_BUTTON}-back-button`, ); @@ -108,7 +108,6 @@ class PerpsMarketListView { // Generic selector for first market row item (regardless of coin) get firstMarketRowItem() { - // Match any element with testID that starts with 'perps-market-row-item-' and get the first one return Matchers.getElementByID(/^perps-market-row-item-.*/, 0); } diff --git a/tests/page-objects/Perps/PerpsOrderView.ts b/tests/page-objects/Perps/PerpsOrderView.ts index 9e66a2af815..a9ceb576502 100644 --- a/tests/page-objects/Perps/PerpsOrderView.ts +++ b/tests/page-objects/Perps/PerpsOrderView.ts @@ -64,7 +64,7 @@ class PerpsOrderView { } // Leverage chip by visible text, e.g., "3x", "10x", "20x" - leverageOption(leverageX: number, index = 0): DetoxElement { + leverageOption(leverageX: number, index = 0): EncapsulatedElementType { return Matchers.getElementByText(`${leverageX}x`, index); } @@ -77,7 +77,7 @@ class PerpsOrderView { } // Modal title to ensure the leverage bottom sheet is visible - get leverageModalTitle(): DetoxElement { + get leverageModalTitle(): EncapsulatedElementType { return Matchers.getElementByText('Set Leverage'); } @@ -283,16 +283,16 @@ class PerpsOrderView { } // Order type / Limit Price helpers - private get orderTypeMarket(): DetoxElement { + private get orderTypeMarket(): EncapsulatedElementType { return Matchers.getElementByText('Market'); } - private get orderTypeSelector(): DetoxElement { + private get orderTypeSelector(): EncapsulatedElementType { return Matchers.getElementByID( PerpsOrderHeaderSelectorsIDs.ORDER_TYPE_BUTTON, ); } - private get orderTypeLimit(): DetoxElement { + private get orderTypeLimit(): EncapsulatedElementType { return Matchers.getElementByText('Limit'); } diff --git a/tests/page-objects/Perps/PerpsView.ts b/tests/page-objects/Perps/PerpsView.ts index 3e0b0c6e4a6..d1d2027a212 100644 --- a/tests/page-objects/Perps/PerpsView.ts +++ b/tests/page-objects/Perps/PerpsView.ts @@ -14,6 +14,7 @@ import Utilities from '../../framework/Utilities'; import { waitForStableEnabledIOS } from './waitForStableEnabledIOS'; import PerpsHomeView from './PerpsHomeView'; import PerpsMarketListView from './PerpsMarketListView'; +import { EncapsulatedElementType } from '../../framework'; /** Portfolio: limit order primary (`formatOrderLabel`) + position primary (`{symbol} {n}x {side}`). */ export interface PerpsPortfolioLimitFlowExpectOptions { @@ -35,7 +36,7 @@ class PerpsView { leverageX: number, direction: 'long' | 'short', index = 0, - ): DetoxElement { + ): EncapsulatedElementType { return Matchers.getElementByID( new RegExp( `^perps-positions-item-${symbol}-${leverageX}x-${direction}-${index}$`, @@ -51,7 +52,7 @@ class PerpsView { symbol: string, direction: 'long' | 'short', index = 0, - ): DetoxElement { + ): EncapsulatedElementType { const escapedSymbol = symbol.replace(/[.*+?^${}()|[\]\\]/g, '\\$&'); return Matchers.getElementByID( new RegExp( @@ -67,11 +68,11 @@ class PerpsView { } // "Edit TP/SL" button visible on position details - get editTpslButton(): DetoxElement { + get editTpslButton(): EncapsulatedElementType { return Matchers.getElementByText('Edit TP/SL'); } - get closePositionBottomSheetButton(): DetoxElement { + get closePositionBottomSheetButton(): EncapsulatedElementType { return Matchers.getElementByID( PerpsClosePositionViewSelectorsIDs.CLOSE_POSITION_CONFIRM_BUTTON, ); @@ -105,21 +106,21 @@ class PerpsView { return Matchers.getElementByText('Dismiss'); } - get anchor(): DetoxElement { + get anchor(): EncapsulatedElementType { return Matchers.getElementByID('perps-tab-scroll-view'); } /** Perps home header — use as swipe target when {@link anchor} is absent. */ - get perpsHomeHeader(): DetoxElement { + get perpsHomeHeader(): EncapsulatedElementType { return Matchers.getElementByID('perps-home'); } // Orders section on the Perps main tab - get ordersSectionTitle(): DetoxElement { + get ordersSectionTitle(): EncapsulatedElementType { return Matchers.getElementByText('Orders'); } - get anyOrderCardOnTab(): DetoxElement { + get anyOrderCardOnTab(): EncapsulatedElementType { // PerpsCard has no specific testID for orders; assert by the presence of the title and any text matching limit label return Matchers.getElementByText('Limit'); } diff --git a/tests/page-objects/Predict/PredictAddFunds.ts b/tests/page-objects/Predict/PredictAddFunds.ts index cb872443f98..dda3e74734c 100644 --- a/tests/page-objects/Predict/PredictAddFunds.ts +++ b/tests/page-objects/Predict/PredictAddFunds.ts @@ -1,8 +1,8 @@ -import { Matchers, Gestures } from '../../framework'; +import { Matchers, Gestures, EncapsulatedElementType } from '../../framework'; import { PredictAddFundsSelectorText } from '../../../app/components/UI/Predict/Predict.testIds'; class PredictAddFunds { - get addFundsButton(): DetoxElement { + get addFundsButton(): EncapsulatedElementType { return Matchers.getElementByText(PredictAddFundsSelectorText.ADD_FUNDS); } diff --git a/tests/page-objects/Predict/PredictBalance.ts b/tests/page-objects/Predict/PredictBalance.ts index 54836b1d188..a2083fb1038 100644 --- a/tests/page-objects/Predict/PredictBalance.ts +++ b/tests/page-objects/Predict/PredictBalance.ts @@ -1,12 +1,18 @@ -import { Matchers, Gestures, Assertions, Utilities } from '../../framework'; +import { + Matchers, + Gestures, + Assertions, + Utilities, + EncapsulatedElementType, +} from '../../framework'; import { PredictBalanceSelectorsIDs } from '../../../app/components/UI/Predict/Predict.testIds'; class PredictBalance { - get balanceCard(): DetoxElement { + get balanceCard(): EncapsulatedElementType { return Matchers.getElementByID(PredictBalanceSelectorsIDs.BALANCE_CARD); } - get withdrawButton(): DetoxElement { + get withdrawButton(): EncapsulatedElementType { return Matchers.getElementByID(PredictBalanceSelectorsIDs.WITHDRAW_BUTTON); } diff --git a/tests/page-objects/Predict/PredictCashOutPage.ts b/tests/page-objects/Predict/PredictCashOutPage.ts index b05b7e89648..66f70222588 100644 --- a/tests/page-objects/Predict/PredictCashOutPage.ts +++ b/tests/page-objects/Predict/PredictCashOutPage.ts @@ -1,11 +1,11 @@ -import { Matchers, Gestures } from '../../framework'; +import { Matchers, Gestures, EncapsulatedElementType } from '../../framework'; import { PredictCashOutSelectorsIDs } from '../../../app/components/UI/Predict/Predict.testIds'; class PredictCashOutPage { - get container(): DetoxElement { + get container(): EncapsulatedElementType { return Matchers.getElementByID(PredictCashOutSelectorsIDs.CONTAINER); } - get cashOutButton(): DetoxElement { + get cashOutButton(): EncapsulatedElementType { return Matchers.getElementByID( PredictCashOutSelectorsIDs.SELL_PREVIEW_CASH_OUT_BUTTON, ); diff --git a/tests/page-objects/Predict/PredictClaimPage.ts b/tests/page-objects/Predict/PredictClaimPage.ts index 35259ca14b7..5478e61e312 100644 --- a/tests/page-objects/Predict/PredictClaimPage.ts +++ b/tests/page-objects/Predict/PredictClaimPage.ts @@ -1,13 +1,13 @@ -import { Matchers, Gestures } from '../../framework'; +import { Matchers, Gestures, EncapsulatedElementType } from '../../framework'; import { PredictClaimConfirmationSelectorsIDs } from '../../../app/components/UI/Predict/Predict.testIds'; class PredictClaimPage { - get container(): DetoxElement { + get container(): EncapsulatedElementType { return Matchers.getElementByID( PredictClaimConfirmationSelectorsIDs.CLAIM_BACKGROUND_CONTAINER, ); } - get claimConfirmButton(): DetoxElement { + get claimConfirmButton(): EncapsulatedElementType { return Matchers.getElementByID( PredictClaimConfirmationSelectorsIDs.CLAIM_CONFIRM_BUTTON, ); diff --git a/tests/page-objects/Predict/PredictUnavailableView.ts b/tests/page-objects/Predict/PredictUnavailableView.ts index 34be4fcd994..5cf209fd127 100644 --- a/tests/page-objects/Predict/PredictUnavailableView.ts +++ b/tests/page-objects/Predict/PredictUnavailableView.ts @@ -2,19 +2,20 @@ import Assertions from '../../framework/Assertions'; import Gestures from '../../framework/Gestures'; import Matchers from '../../framework/Matchers'; import { PredictUnavailableSelectorsIDs } from '../../../app/components/UI/Predict/Predict.testIds'; +import { EncapsulatedElementType } from '../../framework'; class PredictUnavailableView { - get title(): DetoxElement { + get title(): EncapsulatedElementType { return Matchers.getElementByText(PredictUnavailableSelectorsIDs.TITLE_TEXT); } - get description(): DetoxElement { + get description(): EncapsulatedElementType { return Matchers.getElementByText( PredictUnavailableSelectorsIDs.DESCRIPTION_TEXT, ); } - get gotItButton(): DetoxElement { + get gotItButton(): EncapsulatedElementType { return Matchers.getElementByText( PredictUnavailableSelectorsIDs.BUTTON_TEXT, ); diff --git a/tests/page-objects/Ramps/BuildQuoteView.ts b/tests/page-objects/Ramps/BuildQuoteView.ts index 1d25c3b8c69..4521929bcfd 100644 --- a/tests/page-objects/Ramps/BuildQuoteView.ts +++ b/tests/page-objects/Ramps/BuildQuoteView.ts @@ -4,91 +4,92 @@ import Utilities from '../../framework/Utilities'; import { BuildQuoteSelectors } from '../../../app/components/UI/Ramp/Aggregator/Views/BuildQuote/BuildQuote.testIds'; import { AddressSelectorSelectors } from '../../../app/components/Views/AddressSelector/AddressSelector.testIds'; +import { EncapsulatedElementType } from '../../framework'; class BuildQuoteView { - get amountToBuyLabel(): DetoxElement { + get amountToBuyLabel(): EncapsulatedElementType { return Matchers.getElementByText(BuildQuoteSelectors.AMOUNT_TO_BUY_LABEL); } - get amountToSellLabel(): DetoxElement { + get amountToSellLabel(): EncapsulatedElementType { return Matchers.getElementByText(BuildQuoteSelectors.AMOUNT_TO_SELL_LABEL); } - get getQuotesButton(): DetoxElement { + get getQuotesButton(): EncapsulatedElementType { return Matchers.getElementByText(BuildQuoteSelectors.GET_QUOTES_BUTTON); } - get cancelButton(): DetoxElement { + get cancelButton(): EncapsulatedElementType { return Matchers.getElementByText(BuildQuoteSelectors.CANCEL_BUTTON_TEXT); } - get selectRegionDropdown(): DetoxElement { + get selectRegionDropdown(): EncapsulatedElementType { return Matchers.getElementByText(BuildQuoteSelectors.SELECT_REGION); } - get selectPaymentMethodDropdown(): DetoxElement { + get selectPaymentMethodDropdown(): EncapsulatedElementType { return Matchers.getElementByText(BuildQuoteSelectors.SELECT_PAYMENT_METHOD); } - get selectCurrencyDropdown(): DetoxElement { + get selectCurrencyDropdown(): EncapsulatedElementType { return Matchers.getElementByID(BuildQuoteSelectors.SELECT_CURRENCY); } - get amountInput(): DetoxElement { + get amountInput(): EncapsulatedElementType { return Matchers.getElementByID(BuildQuoteSelectors.AMOUNT_INPUT); } - get regionDropdown(): DetoxElement { + get regionDropdown(): EncapsulatedElementType { return Matchers.getElementByID(BuildQuoteSelectors.REGION_DROPDOWN); } - get accountPicker(): DetoxElement { + get accountPicker(): EncapsulatedElementType { return Matchers.getElementByID(BuildQuoteSelectors.ACCOUNT_PICKER); } - get minLimitErrorMessage(): DetoxElement { + get minLimitErrorMessage(): EncapsulatedElementType { return Matchers.getElementByID(BuildQuoteSelectors.MIN_LIMIT_ERROR); } - get maxLimitErrorMessage(): DetoxElement { + get maxLimitErrorMessage(): EncapsulatedElementType { return Matchers.getElementByID(BuildQuoteSelectors.MAX_LIMIT_ERROR); } - get insufficientBalanceErrorMessage(): DetoxElement { + get insufficientBalanceErrorMessage(): EncapsulatedElementType { return Matchers.getElementByID( BuildQuoteSelectors.INSUFFICIENT_BALANCE_ERROR, ); } - get keypadDeleteButton(): DetoxElement { + get keypadDeleteButton(): EncapsulatedElementType { return Matchers.getElementByID(BuildQuoteSelectors.KEYPAD_DELETE_BUTTON); } - get doneButton(): DetoxElement { + get doneButton(): EncapsulatedElementType { return Matchers.getElementByText(BuildQuoteSelectors.DONE_BUTTON); } - get continueButton(): DetoxElement { + get continueButton(): EncapsulatedElementType { return Matchers.getElementByID(BuildQuoteSelectors.CONTINUE_BUTTON); } - get quickAmount25(): DetoxElement { + get quickAmount25(): EncapsulatedElementType { return Matchers.getElementByLabel('25%'); } - get quickAmount50(): DetoxElement { + get quickAmount50(): EncapsulatedElementType { return Matchers.getElementByLabel('50%'); } - get quickAmount75(): DetoxElement { + get quickAmount75(): EncapsulatedElementType { return Matchers.getElementByLabel('75%'); } - get quickAmountMax(): DetoxElement { + get quickAmountMax(): EncapsulatedElementType { return Matchers.getElementByLabel('MAX'); } - get accountPickerDropdown(): DetoxElement { + get accountPickerDropdown(): EncapsulatedElementType { return Matchers.getElementByID( AddressSelectorSelectors.ACCOUNT_PICKER_DROPDOWN, ); @@ -188,7 +189,10 @@ class BuildQuoteView { async tapPaymentMethodDropdown( paymentMethod: string | RegExp, ): Promise { - const paymentMethodOption = Matchers.getElementByText(paymentMethod); + const paymentMethodOption = + typeof paymentMethod === 'string' + ? Matchers.getElementByText(paymentMethod) + : Matchers.getElementByText(paymentMethod as RegExp); await Gestures.waitAndTap(paymentMethodOption, { elemDescription: `Payment Method Dropdown (${paymentMethod}) in Build Quote View`, }); @@ -198,7 +202,9 @@ class BuildQuoteView { regex: string | RegExp, ): Promise { try { - const elem = await Matchers.getElementByText(regex); + const elem = await (typeof regex === 'string' + ? Matchers.getElementByText(regex) + : Matchers.getElementByText(regex as RegExp)); const attributes = await ( elem as unknown as IndexableNativeElement ).getAttributes(); diff --git a/tests/page-objects/Ramps/BuyGetStartedView.ts b/tests/page-objects/Ramps/BuyGetStartedView.ts index f89e6ecf7c1..3a4f5e50647 100644 --- a/tests/page-objects/Ramps/BuyGetStartedView.ts +++ b/tests/page-objects/Ramps/BuyGetStartedView.ts @@ -1,9 +1,10 @@ import Matchers from '../../framework/Matchers'; import Gestures from '../../framework/Gestures'; import { GetStartedSelectors } from '../../selectors/Ramps/GetStarted.selectors'; +import { EncapsulatedElementType } from '../../framework'; class BuyGetStartedView { - get getStartedButton(): DetoxElement { + get getStartedButton(): EncapsulatedElementType { return Matchers.getElementByText(GetStartedSelectors.GET_STARTED); } diff --git a/tests/page-objects/Ramps/KYCScreen.ts b/tests/page-objects/Ramps/KYCScreen.ts index 3ce9aa09743..123cce6fcce 100644 --- a/tests/page-objects/Ramps/KYCScreen.ts +++ b/tests/page-objects/Ramps/KYCScreen.ts @@ -3,25 +3,25 @@ import { OtpCodeSelectorsIDs } from '../../../app/components/UI/Ramp/Views/Nativ import { VerifyIdentitySelectorsIDs } from '../../../app/components/UI/Ramp/Views/NativeFlow/VerifyIdentity.testIds'; import Matchers from '../../framework/Matchers'; import Gestures from '../../framework/Gestures'; -import { Utilities } from '../../framework'; +import { Utilities, EncapsulatedElementType } from '../../framework'; class KYCScreen { - get verifyIdentityContinueButton(): DetoxElement { + get verifyIdentityContinueButton(): EncapsulatedElementType { return Matchers.getElementByID(VerifyIdentitySelectorsIDs.CONTINUE_BUTTON); } - get emailInput(): DetoxElement { + get emailInput(): EncapsulatedElementType { return Matchers.getElementByID(EnterEmailSelectorsIDs.EMAIL_INPUT); } - get sendEmailButton(): DetoxElement { + get sendEmailButton(): EncapsulatedElementType { return Matchers.getElementByID(EnterEmailSelectorsIDs.SEND_EMAIL_BUTTON); } - get otpScreen(): DetoxElement { + get otpScreen(): EncapsulatedElementType { return Matchers.getElementByID(OtpCodeSelectorsIDs.OTP_CODE_SCREEN); } - get otpCodeInput(): DetoxElement { + get otpCodeInput(): EncapsulatedElementType { return Matchers.getElementByID(OtpCodeSelectorsIDs.OTP_CODE_INPUT); } diff --git a/tests/page-objects/Ramps/OrderDetailsView.ts b/tests/page-objects/Ramps/OrderDetailsView.ts index deec2a925b7..76b94e0a3d2 100644 --- a/tests/page-objects/Ramps/OrderDetailsView.ts +++ b/tests/page-objects/Ramps/OrderDetailsView.ts @@ -2,20 +2,21 @@ import { RampsOrderDetailsSelectorsIDs } from '../../../app/components/UI/Ramp/V import Matchers from '../../framework/Matchers'; import Gestures from '../../framework/Gestures'; import Utilities from '../../framework/Utilities'; +import { EncapsulatedElementType } from '../../framework'; class OrderDetailsView { - get container(): DetoxElement { + get container(): EncapsulatedElementType { return Matchers.getElementByID(RampsOrderDetailsSelectorsIDs.CONTAINER); } - get closeButton(): DetoxElement { + get closeButton(): EncapsulatedElementType { return Matchers.getElementByID(RampsOrderDetailsSelectorsIDs.CLOSE_BUTTON); } - get tokenAmount(): DetoxElement { + get tokenAmount(): EncapsulatedElementType { return Matchers.getElementByID(RampsOrderDetailsSelectorsIDs.TOKEN_AMOUNT); } - get backButton(): DetoxElement { + get backButton(): EncapsulatedElementType { return Matchers.getElementByID(RampsOrderDetailsSelectorsIDs.BACK_BUTTON); } diff --git a/tests/page-objects/Ramps/QuotesView.ts b/tests/page-objects/Ramps/QuotesView.ts index 11ac34f9de0..927a14bec10 100644 --- a/tests/page-objects/Ramps/QuotesView.ts +++ b/tests/page-objects/Ramps/QuotesView.ts @@ -1,29 +1,30 @@ import Matchers from '../../framework/Matchers'; import Gestures from '../../framework/Gestures'; import { QuoteSelectors } from '../../../app/components/UI/Ramp/Aggregator/Views/Quotes/Quotes.testIds'; +import { EncapsulatedElementType } from '../../framework'; class QuotesView { - get selectAQuoteLabel(): DetoxElement { + get selectAQuoteLabel(): EncapsulatedElementType { return Matchers.getElementByText(QuoteSelectors.RECOMMENDED_QUOTE); } - get quoteAmountLabel(): DetoxElement { + get quoteAmountLabel(): EncapsulatedElementType { return Matchers.getElementByID(QuoteSelectors.QUOTE_AMOUNT_LABEL); } - get quotes(): DetoxElement { + get quotes(): EncapsulatedElementType { return Matchers.getElementByID(QuoteSelectors.QUOTES); } - get exploreMoreOptions(): DetoxElement { + get exploreMoreOptions(): EncapsulatedElementType { return Matchers.getElementByText(QuoteSelectors.EXPLORE_MORE_OPTIONS); } - get expandedQuotesSection(): DetoxElement { + get expandedQuotesSection(): EncapsulatedElementType { return Matchers.getElementByID(QuoteSelectors.EXPANDED_QUOTES_SECTION); } - get continueWithProvider(): DetoxElement { + get continueWithProvider(): EncapsulatedElementType { const providerLocator = QuoteSelectors.CONTINUE_WITH_PROVIDER.replace( '{{provider}}', '.*', diff --git a/tests/page-objects/Ramps/SelectPaymentMethodView.ts b/tests/page-objects/Ramps/SelectPaymentMethodView.ts index cad8b154d44..145a879c372 100644 --- a/tests/page-objects/Ramps/SelectPaymentMethodView.ts +++ b/tests/page-objects/Ramps/SelectPaymentMethodView.ts @@ -1,9 +1,10 @@ import Matchers from '../../framework/Matchers'; import Gestures from '../../framework/Gestures'; import { SelectPaymentMethodSelectors } from '../../selectors/Ramps/SelectPaymentMethod.selectors'; +import { EncapsulatedElementType } from '../../framework'; class SelectPaymentMethodView { - get continueButton(): DetoxElement { + get continueButton(): EncapsulatedElementType { return Matchers.getElementByText( SelectPaymentMethodSelectors.CONTINUE_BUTTON, ); diff --git a/tests/page-objects/Ramps/SelectRegionView.ts b/tests/page-objects/Ramps/SelectRegionView.ts index a1fe0fa0b9c..09f86a35d7f 100644 --- a/tests/page-objects/Ramps/SelectRegionView.ts +++ b/tests/page-objects/Ramps/SelectRegionView.ts @@ -1,13 +1,14 @@ import Matchers from '../../framework/Matchers'; import Gestures from '../../framework/Gestures'; import { SelectRegionSelectors } from '../../selectors/Ramps/SelectRegion.selectors'; +import { EncapsulatedElementType } from '../../framework'; class SelectRegionView { - get continueButton(): DetoxElement { + get continueButton(): EncapsulatedElementType { return Matchers.getElementByText(SelectRegionSelectors.CONTINUE_BUTTON); } - get regionSearchInput(): DetoxElement { + get regionSearchInput(): EncapsulatedElementType { return Matchers.getElementByID( SelectRegionSelectors.REGION_MODAL_SEARCH_INPUT, ); diff --git a/tests/page-objects/Ramps/SellGetStartedView.ts b/tests/page-objects/Ramps/SellGetStartedView.ts index 513351ba0b2..6f43c980251 100644 --- a/tests/page-objects/Ramps/SellGetStartedView.ts +++ b/tests/page-objects/Ramps/SellGetStartedView.ts @@ -1,9 +1,10 @@ import Matchers from '../../framework/Matchers'; import Gestures from '../../framework/Gestures'; import { GetStartedSelectors } from '../../selectors/Ramps/GetStarted.selectors'; +import { EncapsulatedElementType } from '../../framework'; class SellGetStartedView { - get getStartedButton(): DetoxElement { + get getStartedButton(): EncapsulatedElementType { return Matchers.getElementByText(GetStartedSelectors.GET_STARTED); } diff --git a/tests/page-objects/Ramps/TokenSelectScreen.ts b/tests/page-objects/Ramps/TokenSelectScreen.ts index c6feebd2d3e..69056235f77 100644 --- a/tests/page-objects/Ramps/TokenSelectScreen.ts +++ b/tests/page-objects/Ramps/TokenSelectScreen.ts @@ -1,9 +1,10 @@ import Matchers from '../../framework/Matchers'; import Gestures from '../../framework/Gestures'; import { selectTokenSelectors } from '../../../app/components/UI/Ramp/Aggregator/components/TokenSelectModal/SelectToken.testIds'; +import { EncapsulatedElementType } from '../../framework'; class TokenSelectScreen { - get tokenSearchInput(): DetoxElement { + get tokenSearchInput(): EncapsulatedElementType { return Matchers.getElementByID( selectTokenSelectors.TOKEN_SELECT_MODAL_SEARCH_INPUT, ); diff --git a/tests/page-objects/Send/AddAddressModal.ts b/tests/page-objects/Send/AddAddressModal.ts index ddc94b3a64d..23b3d83bb4a 100644 --- a/tests/page-objects/Send/AddAddressModal.ts +++ b/tests/page-objects/Send/AddAddressModal.ts @@ -1,25 +1,26 @@ import { AddAddressModalSelectorsIDs } from '../../../app/components/UI/AddToAddressBookWrapper/AddAddressModal.testIds'; import Matchers from '../../framework/Matchers'; import Gestures from '../../framework/Gestures'; +import { EncapsulatedElementType } from '../../framework'; class AddAddressModal { - get container(): DetoxElement { + get container(): EncapsulatedElementType { return Matchers.getElementByID(AddAddressModalSelectorsIDs.CONTAINER); } - get aliasInput(): DetoxElement { + get aliasInput(): EncapsulatedElementType { return Matchers.getElementByID( AddAddressModalSelectorsIDs.ENTER_ALIAS_INPUT, ); } - get saveButton(): DetoxElement { + get saveButton(): EncapsulatedElementType { return device.getPlatform() === 'android' ? Matchers.getElementByLabel(AddAddressModalSelectorsIDs.SAVE_BUTTON) : Matchers.getElementByID(AddAddressModalSelectorsIDs.SAVE_BUTTON); } - get title(): DetoxElement { + get title(): EncapsulatedElementType { return Matchers.getElementByID(AddAddressModalSelectorsIDs.TITLE); } diff --git a/tests/page-objects/Send/RedesignedSendView.ts b/tests/page-objects/Send/RedesignedSendView.ts index 24e80314872..2d8bd5fd8ea 100644 --- a/tests/page-objects/Send/RedesignedSendView.ts +++ b/tests/page-objects/Send/RedesignedSendView.ts @@ -1,7 +1,11 @@ import Gestures from '../../framework/Gestures'; import Matchers from '../../framework/Matchers'; import { RedesignedSendViewSelectorsIDs } from '../../../app/components/Views/confirmations/components/send/RedesignedSendView.testIds'; -import { Utilities, Assertions } from '../../framework'; +import { + Utilities, + Assertions, + EncapsulatedElementType, +} from '../../framework'; import { CommonSelectorsIDs } from '../../../app/util/Common.testIds'; import { SendActionViewSelectorsIDs } from '../../selectors/SendFlow/SendActionView.selectors'; import { encapsulatedAction } from '../../framework/encapsulatedAction'; @@ -12,69 +16,69 @@ import { PlatformDetector } from '../../framework/PlatformLocator'; import { getNetworkFilterTestId } from '../../../app/components/Views/confirmations/components/network-filter/network-filter.testIds'; class SendView { - get ethereumTokenButton(): DetoxElement { + get ethereumTokenButton(): EncapsulatedElementType { return Matchers.getElementByText('Ethereum'); } - get erc20TokenButton(): DetoxElement { + get erc20TokenButton(): EncapsulatedElementType { return Matchers.getElementByText('USD Coin'); } - get zeroButton(): DetoxElement { + get zeroButton(): EncapsulatedElementType { return Matchers.getElementByText('0', 1); } - get amountFiveButton(): DetoxElement { + get amountFiveButton(): EncapsulatedElementType { return Matchers.getElementByText('5'); } - get fiftyPercentButton(): DetoxElement { + get fiftyPercentButton(): EncapsulatedElementType { return Matchers.getElementByID('percentage-button-50'); } - get maxButton(): DetoxElement { + get maxButton(): EncapsulatedElementType { return Matchers.getElementByID('percentage-button-100'); } - get continueButton(): DetoxElement { + get continueButton(): EncapsulatedElementType { return Matchers.getElementByText('Continue'); } - get recipientAddressInput(): DetoxElement { + get recipientAddressInput(): EncapsulatedElementType { return Matchers.getElementByID( RedesignedSendViewSelectorsIDs.RECIPIENT_ADDRESS_INPUT, ); } - get reviewButton(): DetoxElement { + get reviewButton(): EncapsulatedElementType { return Matchers.getElementByID( RedesignedSendViewSelectorsIDs.REVIEW_BUTTON, ); } - get amountInputField(): DetoxElement { + get amountInputField(): EncapsulatedElementType { return Matchers.getElementByID('txn-amount-input'); } - get nextButton(): DetoxElement { + get nextButton(): EncapsulatedElementType { return Matchers.getElementByID('txn-amount-next-button'); } - get currencySwitch(): DetoxElement { + get currencySwitch(): EncapsulatedElementType { return Matchers.getElementByID('amount-screen-currency-switch'); } - get backButton(): DetoxElement { + get backButton(): EncapsulatedElementType { return Matchers.getElementByID(CommonSelectorsIDs.BACK_ARROW_BUTTON); } - get insufficientBalanceToCoverFeesError(): DetoxElement { + get insufficientBalanceToCoverFeesError(): EncapsulatedElementType { return Matchers.getElementByText( SendActionViewSelectorsIDs.INSUFFICIENT_BALANCE_TO_COVER_FEES_ERROR, ); } - get insufficientFundsError(): DetoxElement { + get insufficientFundsError(): EncapsulatedElementType { return Matchers.getElementByText( SendActionViewSelectorsIDs.INSUFFICIENT_FUNDS_ERROR, ); diff --git a/tests/page-objects/Send/TransactionConfirmView.ts b/tests/page-objects/Send/TransactionConfirmView.ts index 9fb8b53aa45..13cbec55ee1 100644 --- a/tests/page-objects/Send/TransactionConfirmView.ts +++ b/tests/page-objects/Send/TransactionConfirmView.ts @@ -15,6 +15,7 @@ import { // TransactionConfirmViewSelectorsText, // } from '../../../app/components/Views/confirmations/legacy/components/Confirm/TransactionConfirmView.testIds'; import RowComponents from '../Browser/Confirmations/RowComponents'; +import { EncapsulatedElementType } from '../../framework'; // Temporary placeholders to prevent TypeScript errors const TransactionConfirmViewSelectorsIDs = { @@ -29,7 +30,7 @@ const TransactionConfirmViewSelectorsText = { }; class TransactionConfirmationView { - get confirmButton(): DetoxElement { + get confirmButton(): EncapsulatedElementType { return device.getPlatform() === 'ios' ? Matchers.getElementByID( TransactionConfirmViewSelectorsIDs.CONFIRM_TRANSACTION_BUTTON_ID, @@ -39,68 +40,68 @@ class TransactionConfirmationView { ); } - get cancelButton(): DetoxElement { + get cancelButton(): EncapsulatedElementType { return Matchers.getElementByText( TransactionConfirmViewSelectorsText.CANCEL_BUTTON, ); } - get maxGasFee(): DetoxElement { + get maxGasFee(): EncapsulatedElementType { return Matchers.getElementByID( EditGasViewSelectorsIDs.MAX_PRIORITY_FEE_INPUT_TEST_ID, ); } - get editPriorityFeeSheetContainer(): DetoxElement { + get editPriorityFeeSheetContainer(): EncapsulatedElementType { return Matchers.getElementByID( EditGasViewSelectorsIDs.EDIT_PRIORITY_SCREEN_TEST_ID, ); } - get transactionAmount(): DetoxElement { + get transactionAmount(): EncapsulatedElementType { return Matchers.getElementByID( TransactionConfirmViewSelectorsIDs.CONFIRM_TXN_AMOUNT, ); } - get estimatedGasLink(): DetoxElement { + get estimatedGasLink(): EncapsulatedElementType { return Matchers.getElementByID( EditGasViewSelectorsIDs.ESTIMATED_FEE_TEST_ID, ); } - get transactionViewContainer(): DetoxElement { + get transactionViewContainer(): EncapsulatedElementType { return Matchers.getElementByID( TransactionConfirmViewSelectorsIDs.TRANSACTION_VIEW_CONTAINER_ID, ); } - get LowPriorityText(): DetoxElement { + get LowPriorityText(): EncapsulatedElementType { return Matchers.getElementByText(EditGasViewSelectorsText.LOW); } - get MarketPriorityText(): DetoxElement { + get MarketPriorityText(): EncapsulatedElementType { return Matchers.getElementByText(EditGasViewSelectorsText.MARKET); } - get AggressivePriorityText(): DetoxElement { + get AggressivePriorityText(): EncapsulatedElementType { return Matchers.getElementByText(EditGasViewSelectorsText.AGGRESSIVE); } - get EditPrioritySaveButtonText(): DetoxElement { + get EditPrioritySaveButtonText(): EncapsulatedElementType { return Matchers.getElementByText(EditGasViewSelectorsText.SAVE_BUTTON); } - get EditPriorityAdvancedOptionsText(): DetoxElement { + get EditPriorityAdvancedOptionsText(): EncapsulatedElementType { return Matchers.getElementByText(EditGasViewSelectorsText.ADVANCE_OPTIONS); } - get editPriorityLegacyModal(): DetoxElement { + get editPriorityLegacyModal(): EncapsulatedElementType { return Matchers.getElementByID(EditGasViewSelectorsIDs.LEGACY_CONTAINER); } - get securityAlertBanner(): DetoxElement { + get securityAlertBanner(): EncapsulatedElementType { return Matchers.getElementByID( ConfirmationTopSheetSelectorsIDs.SECURITY_ALERT_BANNER, ); } - get securityAlertResponseFailedBanner(): DetoxElement { + get securityAlertResponseFailedBanner(): EncapsulatedElementType { return Matchers.getElementByID( ConfirmationTopSheetSelectorsIDs.SECURITY_ALERT_RESPONSE_FAILED_BANNER, ); diff --git a/tests/page-objects/Settings/Advanced/FiatOnTestnetsBottomSheet.ts b/tests/page-objects/Settings/Advanced/FiatOnTestnetsBottomSheet.ts index 9e372c20871..989ad2b3ee2 100644 --- a/tests/page-objects/Settings/Advanced/FiatOnTestnetsBottomSheet.ts +++ b/tests/page-objects/Settings/Advanced/FiatOnTestnetsBottomSheet.ts @@ -1,9 +1,10 @@ import { FiatOnTestnetsBottomSheetSelectorsIDs } from '../../../../app/components/Views/Settings/AdvancedSettings/FiatOnTestnetsFriction/FiatOnTestnetsBottomSheet.testIds'; import Gestures from '../../../framework/Gestures'; import Matchers from '../../../framework/Matchers'; +import { EncapsulatedElementType } from '../../../framework'; class FiatOnTestnetsBottomSheet { - get continueButton(): DetoxElement { + get continueButton(): EncapsulatedElementType { return Matchers.getElementByID( FiatOnTestnetsBottomSheetSelectorsIDs.CONTINUE_BUTTON, ); diff --git a/tests/page-objects/Settings/AdvancedView.ts b/tests/page-objects/Settings/AdvancedView.ts index 748455d182d..522c412fcfa 100644 --- a/tests/page-objects/Settings/AdvancedView.ts +++ b/tests/page-objects/Settings/AdvancedView.ts @@ -4,6 +4,7 @@ import { } from '../../../app/components/Views/Settings/AdvancedSettings/AdvancedView.testIds'; import Gestures from '../../framework/Gestures'; import Matchers from '../../framework/Matchers'; +import { EncapsulatedElementType } from '../../framework'; class AdvancedSettingsView { get scrollViewIdentifier(): Promise { @@ -12,24 +13,24 @@ class AdvancedSettingsView { ); } - get showFiatOnTestnetsToggle(): DetoxElement { + get showFiatOnTestnetsToggle(): EncapsulatedElementType { return Matchers.getElementByID( AdvancedViewSelectorsIDs.SHOW_FIAT_ON_TESTNETS, ); } - get smartTransactionsToggle(): DetoxElement { + get smartTransactionsToggle(): EncapsulatedElementType { return Matchers.getElementByID(AdvancedViewSelectorsIDs.STX_OPT_IN_SWITCH); } - get resetAccountButton(): DetoxElement { + get resetAccountButton(): EncapsulatedElementType { return Matchers.getElementByText( AdvancedViewSelectorsText.RESET_ACCOUNT, 1, ); } - get resetConfirmButton(): DetoxElement { + get resetConfirmButton(): EncapsulatedElementType { return Matchers.getElementByID( AdvancedViewSelectorsIDs.RESET_ACCOUNT_CONFIRM_BUTTON, ); diff --git a/tests/page-objects/Settings/AesCryptoTestForm.ts b/tests/page-objects/Settings/AesCryptoTestForm.ts index 1449cbc4b94..f3462347905 100644 --- a/tests/page-objects/Settings/AesCryptoTestForm.ts +++ b/tests/page-objects/Settings/AesCryptoTestForm.ts @@ -8,6 +8,7 @@ import { } from '../../../app/components/Views/AesCryptoTestForm/AesCrypto.testIds'; import Matchers from '../../framework/Matchers'; import Gestures from '../../framework/Gestures'; +import { EncapsulatedElementType } from '../../framework'; class AesCryptoTestForm { get scrollViewIdentifier(): Promise { @@ -15,108 +16,108 @@ class AesCryptoTestForm { } // Get account address - get accountAddress(): DetoxElement { + get accountAddress(): EncapsulatedElementType { return Matchers.getElementByID(accountAddress); } // Get response text - get responseText(): DetoxElement { + get responseText(): EncapsulatedElementType { return Matchers.getElementByID(responseText); } // Generate salt getters - get generateSaltBytesCountInput(): DetoxElement { + get generateSaltBytesCountInput(): EncapsulatedElementType { return Matchers.getElementByID(aesCryptoFormInputs.saltBytesCountInput); } - get generateSaltResponse(): Promise { + get generateSaltResponse(): EncapsulatedElementType { return Matchers.getElementByID(aesCryptoFormResponses.saltResponse); } - get generateSaltButton(): DetoxElement { + get generateSaltButton(): EncapsulatedElementType { return Matchers.getElementByID(aesCryptoFormButtons.generateSaltButton); } // Generate encryption key from password getters - get generateEncryptionKeyPasswordInput(): DetoxElement { + get generateEncryptionKeyPasswordInput(): EncapsulatedElementType { return Matchers.getElementByID(aesCryptoFormInputs.passwordInput); } - get generateEncryptionKeySaltInput(): DetoxElement { + get generateEncryptionKeySaltInput(): EncapsulatedElementType { return Matchers.getElementByID( aesCryptoFormInputs.saltInputForEncryptionKey, ); } - get generateEncryptionKeyResponse(): Promise { + get generateEncryptionKeyResponse(): EncapsulatedElementType { return Matchers.getElementByID( aesCryptoFormResponses.generateEncryptionKeyResponse, ); } - get generateEncryptionKeyButton(): DetoxElement { + get generateEncryptionKeyButton(): EncapsulatedElementType { return Matchers.getElementByID( aesCryptoFormButtons.generateEncryptionKeyButton, ); } // Encrypt getters - get encryptDataInput(): DetoxElement { + get encryptDataInput(): EncapsulatedElementType { return Matchers.getElementByID(aesCryptoFormInputs.dataInputForEncryption); } - get encryptPasswordInput(): DetoxElement { + get encryptPasswordInput(): EncapsulatedElementType { return Matchers.getElementByID( aesCryptoFormInputs.passwordInputForEncryption, ); } - get encryptResponse(): DetoxElement { + get encryptResponse(): EncapsulatedElementType { return Matchers.getElementByID(aesCryptoFormResponses.encryptionResponse); } - get encryptButton(): DetoxElement { + get encryptButton(): EncapsulatedElementType { return Matchers.getElementByID(aesCryptoFormButtons.encryptButton); } // Decrypt getters - get decryptPasswordInput(): DetoxElement { + get decryptPasswordInput(): EncapsulatedElementType { return Matchers.getElementByID( aesCryptoFormInputs.passwordInputForDecryption, ); } - get decryptResponse(): DetoxElement { + get decryptResponse(): EncapsulatedElementType { return Matchers.getElementByID(aesCryptoFormResponses.decryptionResponse); } - get decryptButton(): DetoxElement { + get decryptButton(): EncapsulatedElementType { return Matchers.getElementByID(aesCryptoFormButtons.decryptButton); } // Encrypt with key getters - get encryptWithKeyEncryptionKeyInput(): DetoxElement { + get encryptWithKeyEncryptionKeyInput(): EncapsulatedElementType { return Matchers.getElementByID( aesCryptoFormInputs.encryptionKeyInputForEncryptionWithKey, ); } - get encryptWithKeyDataInput(): DetoxElement { + get encryptWithKeyDataInput(): EncapsulatedElementType { return Matchers.getElementByID( aesCryptoFormInputs.dataInputForEncryptionWithKey, ); } - get encryptWithKeyResponse(): DetoxElement { + get encryptWithKeyResponse(): EncapsulatedElementType { return Matchers.getElementByID( aesCryptoFormResponses.encryptionWithKeyResponse, ); } - get encryptWithKeyButton(): DetoxElement { + get encryptWithKeyButton(): EncapsulatedElementType { return Matchers.getElementByID(aesCryptoFormButtons.encryptWithKeyButton); } // Decrypt with key getters - get decryptWithKeyEncryptionKeyInput(): DetoxElement { + get decryptWithKeyEncryptionKeyInput(): EncapsulatedElementType { return Matchers.getElementByID( aesCryptoFormInputs.encryptionKeyInputForDecryptionWithKey, ); } - get decryptWithKeyResponse(): DetoxElement { + get decryptWithKeyResponse(): EncapsulatedElementType { return Matchers.getElementByID( aesCryptoFormResponses.decryptionWithKeyResponse, ); } - get decryptWithKeyButton(): DetoxElement { + get decryptWithKeyButton(): EncapsulatedElementType { return Matchers.getElementByID(aesCryptoFormButtons.decryptWithKeyButton); } @@ -185,7 +186,7 @@ class AesCryptoTestForm { }); const responseFieldAtts = await ( - await this.generateSaltResponse + (await this.generateSaltResponse) as Detox.IndexableNativeElement ).getAttributes(); return (responseFieldAtts as { label: string }).label; @@ -210,7 +211,7 @@ class AesCryptoTestForm { }); const responseFieldAtts = await ( - await this.generateEncryptionKeyResponse + (await this.generateEncryptionKeyResponse) as Detox.IndexableNativeElement ).getAttributes(); return (responseFieldAtts as { label: string }).label; diff --git a/tests/page-objects/Settings/BackupAndSyncView.ts b/tests/page-objects/Settings/BackupAndSyncView.ts index 3196901d4d4..7de45d1fe83 100644 --- a/tests/page-objects/Settings/BackupAndSyncView.ts +++ b/tests/page-objects/Settings/BackupAndSyncView.ts @@ -1,21 +1,22 @@ import { BackupAndSyncViewSelectorsIDs } from '../../selectors/Settings/BackupAndSyncView.selectors'; import Gestures from '../../framework/Gestures'; import Matchers from '../../framework/Matchers'; +import { EncapsulatedElementType } from '../../framework'; class BackupAndSyncView { - get accountSyncToggle(): DetoxElement { + get accountSyncToggle(): EncapsulatedElementType { return Matchers.getElementByID( BackupAndSyncViewSelectorsIDs.ACCOUNT_SYNC_TOGGLE, ); } - get backupAndSyncToggle(): DetoxElement { + get backupAndSyncToggle(): EncapsulatedElementType { return Matchers.getElementByID( BackupAndSyncViewSelectorsIDs.BACKUP_AND_SYNC_TOGGLE, ); } - get contactSyncToggle(): DetoxElement { + get contactSyncToggle(): EncapsulatedElementType { return Matchers.getElementByID( BackupAndSyncViewSelectorsIDs.CONTACTS_SYNC_TOGGLE, ); diff --git a/tests/page-objects/Settings/Contacts/AddContactView.ts b/tests/page-objects/Settings/Contacts/AddContactView.ts index ab00d3f9c70..388ce927bcb 100644 --- a/tests/page-objects/Settings/Contacts/AddContactView.ts +++ b/tests/page-objects/Settings/Contacts/AddContactView.ts @@ -4,47 +4,48 @@ import { AddContactViewSelectorsText, } from '../../../../app/components/Views/Settings/Contacts/AddContactView.testIds'; import Gestures from '../../../framework/Gestures'; +import { EncapsulatedElementType } from '../../../framework'; class AddContactView { - get container(): DetoxElement { + get container(): EncapsulatedElementType { return Matchers.getElementByID(AddContactViewSelectorsIDs.CONTAINER); } - get addButton(): DetoxElement { + get addButton(): EncapsulatedElementType { return device.getPlatform() === 'ios' ? Matchers.getElementByID(AddContactViewSelectorsIDs.ADD_BUTTON) : Matchers.getElementByLabel(AddContactViewSelectorsIDs.ADD_BUTTON); } - get editButton(): DetoxElement { + get editButton(): EncapsulatedElementType { return device.getPlatform() === 'ios' ? Matchers.getElementByID(AddContactViewSelectorsIDs.EDIT_BUTTON) : Matchers.getElementByLabel(AddContactViewSelectorsText.EDIT_BUTTON); } - get editContact(): DetoxElement { + get editContact(): EncapsulatedElementType { return Matchers.getElementByText(AddContactViewSelectorsText.EDIT_CONTACT); } - get deleteButton(): DetoxElement { + get deleteButton(): EncapsulatedElementType { return device.getPlatform() === 'ios' ? Matchers.getElementByID(AddContactViewSelectorsIDs.DELETE_BUTTON) : Matchers.getElementByLabel(AddContactViewSelectorsIDs.DELETE_BUTTON); } - get nameInput(): DetoxElement { + get nameInput(): EncapsulatedElementType { return Matchers.getElementByID(AddContactViewSelectorsIDs.NAME_INPUT); } - get memoLabel(): DetoxElement { + get memoLabel(): EncapsulatedElementType { return Matchers.getElementByText(AddContactViewSelectorsText.MEMO); } - get memoInput(): DetoxElement { + get memoInput(): EncapsulatedElementType { return Matchers.getElementByID(AddContactViewSelectorsIDs.MEMO_INPUT); } - get addressInput(): DetoxElement { + get addressInput(): EncapsulatedElementType { return Matchers.getElementByID(AddContactViewSelectorsIDs.ADDRESS_INPUT); } diff --git a/tests/page-objects/Settings/Contacts/ContactsView.ts b/tests/page-objects/Settings/Contacts/ContactsView.ts index 7a45772889f..d54189cdbf3 100644 --- a/tests/page-objects/Settings/Contacts/ContactsView.ts +++ b/tests/page-objects/Settings/Contacts/ContactsView.ts @@ -2,13 +2,14 @@ import { ContactsViewSelectorIDs } from '../../../../app/components/Views/Settin import Matchers from '../../../framework/Matchers'; import Gestures from '../../../framework/Gestures'; import Assertions from '../../../framework/Assertions'; +import { EncapsulatedElementType } from '../../../framework'; class ContactsView { - get container(): DetoxElement { + get container(): EncapsulatedElementType { return Matchers.getElementByID(ContactsViewSelectorIDs.CONTAINER); } - get addButton(): DetoxElement { + get addButton(): EncapsulatedElementType { return device.getPlatform() === 'ios' ? Matchers.getElementByID(ContactsViewSelectorIDs.ADD_BUTTON) : Matchers.getElementByLabel(ContactsViewSelectorIDs.ADD_BUTTON); diff --git a/tests/page-objects/Settings/Contacts/DeleteContactBottomSheet.ts b/tests/page-objects/Settings/Contacts/DeleteContactBottomSheet.ts index e7f0b45c27c..889b00da780 100644 --- a/tests/page-objects/Settings/Contacts/DeleteContactBottomSheet.ts +++ b/tests/page-objects/Settings/Contacts/DeleteContactBottomSheet.ts @@ -1,15 +1,16 @@ import Matchers from '../../../framework/Matchers'; import Gestures from '../../../framework/Gestures'; import { DeleteContactBottomSheetSelectorsText } from '../../../selectors/Settings/Contacts/DeleteContactBottomSheet.selectors'; +import { EncapsulatedElementType } from '../../../framework'; class DeleteContactBottomSheet { - get title(): DetoxElement { + get title(): EncapsulatedElementType { return Matchers.getElementByText( DeleteContactBottomSheetSelectorsText.MODAL_TITLE, ); } - get deleteButton(): DetoxElement { + get deleteButton(): EncapsulatedElementType { return device.getPlatform() === 'ios' ? Matchers.getElementByText( DeleteContactBottomSheetSelectorsText.DELETE_BUTTON, diff --git a/tests/page-objects/Settings/NetworksView.ts b/tests/page-objects/Settings/NetworksView.ts index 08fd4c6d8c3..39dcb373bc1 100644 --- a/tests/page-objects/Settings/NetworksView.ts +++ b/tests/page-objects/Settings/NetworksView.ts @@ -5,71 +5,72 @@ import { import Matchers from '../../framework/Matchers'; import Gestures from '../../framework/Gestures'; import { NetworkListModalSelectorsIDs } from '../../../app/components/Views/NetworkSelector/NetworkListModal.testIds'; +import { EncapsulatedElementType } from '../../framework'; class NetworkView { - get networkContainer(): DetoxElement { + get networkContainer(): EncapsulatedElementType { return Matchers.getElementByID(NetworksViewSelectorsIDs.NETWORK_CONTAINER); } - get networkFormContainer(): DetoxElement { + get networkFormContainer(): EncapsulatedElementType { return Matchers.getElementByID(NetworksViewSelectorsIDs.CONTAINER); } - get rpcContainer(): DetoxElement { + get rpcContainer(): EncapsulatedElementType { return Matchers.getElementByID(NetworksViewSelectorsIDs.RPC_CONTAINER); } - get addNetworkButtonForm(): DetoxElement { + get addNetworkButtonForm(): EncapsulatedElementType { return Matchers.getElementByID(NetworkListModalSelectorsIDs.ADD_BUTTON); } - get addRpcDropDownButton(): DetoxElement { + get addRpcDropDownButton(): EncapsulatedElementType { return Matchers.getElementByID(NetworksViewSelectorsIDs.ICON_BUTTON_RPC); } - get addBlockExplorerDropDownButton(): DetoxElement { + get addBlockExplorerDropDownButton(): EncapsulatedElementType { return Matchers.getElementByID( NetworksViewSelectorsIDs.ICON_BUTTON_BLOCK_EXPLORER, ); } - get addBlockExplorerButton(): DetoxElement { + get addBlockExplorerButton(): EncapsulatedElementType { return Matchers.getElementByID(NetworksViewSelectorsIDs.ADD_BLOCK_EXPLORER); } - get addRpcButton(): DetoxElement { + get addRpcButton(): EncapsulatedElementType { return Matchers.getElementByID(NetworksViewSelectorsIDs.ADD_RPC_BUTTON); } - get noMatchingText(): DetoxElement { + get noMatchingText(): EncapsulatedElementType { return Matchers.getElementByText( NetworkViewSelectorsText.NO_MATCHING_SEARCH_RESULTS, ); } - get emptyPopularNetworksText(): DetoxElement { + get emptyPopularNetworksText(): EncapsulatedElementType { return Matchers.getElementByText( NetworkViewSelectorsText.EMPTY_POPULAR_NETWORKS, ); } - get closeIcon(): DetoxElement { + get closeIcon(): EncapsulatedElementType { return Matchers.getElementByID(NetworksViewSelectorsIDs.CLOSE_ICON); } - get deleteNetworkButton(): DetoxElement { + get deleteNetworkButton(): EncapsulatedElementType { return Matchers.getElementByID( NetworksViewSelectorsIDs.REMOVE_NETWORK_BUTTON, ); } - get networkSearchInput(): DetoxElement { + get networkSearchInput(): EncapsulatedElementType { return Matchers.getElementByID( NetworksViewSelectorsIDs.SEARCH_NETWORK_INPUT_BOX_ID, ); } - get addNetworkButton(): DetoxElement { + get addNetworkButton(): EncapsulatedElementType { return device.getPlatform() === 'ios' ? Matchers.getElementByID(NetworksViewSelectorsIDs.ADD_NETWORKS_BUTTON) : Matchers.getElementByLabel( @@ -77,69 +78,69 @@ class NetworkView { ); } - get customNetworkTab(): DetoxElement { + get customNetworkTab(): EncapsulatedElementType { return Matchers.getElementByText( NetworkViewSelectorsText.CUSTOM_NETWORK_TAB, ); } - get networkNameInput(): DetoxElement { + get networkNameInput(): EncapsulatedElementType { return Matchers.getElementByID(NetworksViewSelectorsIDs.NETWORK_NAME_INPUT); } - get rpcURLInput(): DetoxElement { + get rpcURLInput(): EncapsulatedElementType { return Matchers.getElementByID(NetworksViewSelectorsIDs.RPC_URL_INPUT); } - get chainIDInput(): DetoxElement { + get chainIDInput(): EncapsulatedElementType { return Matchers.getElementByID(NetworksViewSelectorsIDs.CHAIN_INPUT); } - get networkSymbolInput(): DetoxElement { + get networkSymbolInput(): EncapsulatedElementType { return Matchers.getElementByID( NetworksViewSelectorsIDs.NETWORKS_SYMBOL_INPUT, ); } - get networkBlockExplorerInput(): DetoxElement { + get networkBlockExplorerInput(): EncapsulatedElementType { return Matchers.getElementByID( NetworksViewSelectorsIDs.BLOCK_EXPLORER_INPUT, ); } - get rpcAddButton(): DetoxElement { + get rpcAddButton(): EncapsulatedElementType { return Matchers.getElementByID( NetworksViewSelectorsIDs.ADD_CUSTOM_NETWORK_BUTTON, ); } - get blockExplorer(): DetoxElement { + get blockExplorer(): EncapsulatedElementType { return Matchers.getElementByLabel(NetworkViewSelectorsText.BLOCK_EXPLORER); } - get chainIdLabel(): DetoxElement { + get chainIdLabel(): EncapsulatedElementType { return Matchers.getElementByLabel(NetworkViewSelectorsText.CHAIN_ID_LABEL); } - get rpcWarningBanner(): DetoxElement { + get rpcWarningBanner(): EncapsulatedElementType { return Matchers.getElementByID(NetworksViewSelectorsIDs.RPC_WARNING_BANNER); } - get customNetworkList(): DetoxElement { + get customNetworkList(): EncapsulatedElementType { return Matchers.getElementByID( NetworksViewSelectorsIDs.CUSTOM_NETWORK_LIST, ); } - get removeNetwork(): DetoxElement { + get removeNetwork(): EncapsulatedElementType { return Matchers.getElementByText(NetworkViewSelectorsText.REMOVE_NETWORK); } - get saveButton(): DetoxElement { + get saveButton(): EncapsulatedElementType { return Matchers.getElementByText(NetworkViewSelectorsText.SAVE_BUTTON); } - async getnetworkName(networkName: string): Promise { + async getnetworkName(networkName: string): Promise { return Matchers.getElementByText(networkName); } async tapAddNetworkButton(): Promise { diff --git a/tests/page-objects/Settings/SecurityAndPrivacy/AutoLockModal.ts b/tests/page-objects/Settings/SecurityAndPrivacy/AutoLockModal.ts index 511d7bec985..a78c42a17bf 100644 --- a/tests/page-objects/Settings/SecurityAndPrivacy/AutoLockModal.ts +++ b/tests/page-objects/Settings/SecurityAndPrivacy/AutoLockModal.ts @@ -1,9 +1,10 @@ import Matchers from '../../../framework/Matchers'; import Gestures from '../../../framework/Gestures'; import { AutoLockModalSelectorsText } from '../../../selectors/Settings/SecurityAndPrivacy/AutoLockModal.selectors'; +import { EncapsulatedElementType } from '../../../framework'; class AutoLockModal { - get autoLockImmediate(): DetoxElement { + get autoLockImmediate(): EncapsulatedElementType { return Matchers.getElementByText( AutoLockModalSelectorsText.AUTO_LOCK_IMMEDIATE, ); diff --git a/tests/page-objects/Settings/SecurityAndPrivacy/ChangePasswordView.ts b/tests/page-objects/Settings/SecurityAndPrivacy/ChangePasswordView.ts index 288761b2b30..99cc3b76000 100644 --- a/tests/page-objects/Settings/SecurityAndPrivacy/ChangePasswordView.ts +++ b/tests/page-objects/Settings/SecurityAndPrivacy/ChangePasswordView.ts @@ -2,33 +2,34 @@ import { ChoosePasswordSelectorsIDs } from '../../../../app/components/Views/Cho import { ChangePasswordViewSelectorsText } from '../../../selectors/Settings/SecurityAndPrivacy/ChangePasswordView.selectors'; import Matchers from '../../../framework/Matchers'; import Gestures from '../../../framework/Gestures'; +import { EncapsulatedElementType } from '../../../framework'; class ChangePasswordView { - get title(): DetoxElement { + get title(): EncapsulatedElementType { return Matchers.getElementByText( ChangePasswordViewSelectorsText.CHANGE_PASSWORD, ); } - get passwordInput(): DetoxElement { + get passwordInput(): EncapsulatedElementType { return Matchers.getElementByID( ChoosePasswordSelectorsIDs.NEW_PASSWORD_INPUT_ID, ); } - get confirmPasswordInput(): DetoxElement { + get confirmPasswordInput(): EncapsulatedElementType { return Matchers.getElementByID( ChoosePasswordSelectorsIDs.CONFIRM_PASSWORD_INPUT_ID, ); } - get iUnderstandCheckBox(): DetoxElement { + get iUnderstandCheckBox(): EncapsulatedElementType { return Matchers.getElementByLabel( ChoosePasswordSelectorsIDs.I_UNDERSTAND_CHECKBOX_ID, ); } - get submitButton(): DetoxElement { + get submitButton(): EncapsulatedElementType { return Matchers.getElementByText( ChoosePasswordSelectorsIDs.SAVE_PASSWORD_BUTTON_TEXT, ); diff --git a/tests/page-objects/Settings/SecurityAndPrivacy/ClearPrivacyModal.ts b/tests/page-objects/Settings/SecurityAndPrivacy/ClearPrivacyModal.ts index aca7a31842d..47fa592513b 100644 --- a/tests/page-objects/Settings/SecurityAndPrivacy/ClearPrivacyModal.ts +++ b/tests/page-objects/Settings/SecurityAndPrivacy/ClearPrivacyModal.ts @@ -4,18 +4,19 @@ import { } from '../../../../app/components/Views/Settings/SecuritySettings/Sections/ClearPrivacy/ClearPrivacyModal.testIds'; import Matchers from '../../../framework/Matchers'; import Gestures from '../../../framework/Gestures'; +import { EncapsulatedElementType } from '../../../framework'; class ClearPrivacyModal { - get container(): DetoxElement { + get container(): EncapsulatedElementType { return Matchers.getElementByID(ClearPrivacyModalSelectorsIDs.CONTAINER); } - get clearButton(): DetoxElement { + get clearButton(): EncapsulatedElementType { return Matchers.getElementByText( ClearPrivacyModalSelectorsText.CLEAR_BUTTON, ); } - get cancelButton(): DetoxElement { + get cancelButton(): EncapsulatedElementType { return Matchers.getElementByText( ClearPrivacyModalSelectorsText.CANCEL_BUTTON, ); diff --git a/tests/page-objects/Settings/SecurityAndPrivacy/DeleteWalletModal.ts b/tests/page-objects/Settings/SecurityAndPrivacy/DeleteWalletModal.ts index 2c2c6b326fc..68e8b1def00 100644 --- a/tests/page-objects/Settings/SecurityAndPrivacy/DeleteWalletModal.ts +++ b/tests/page-objects/Settings/SecurityAndPrivacy/DeleteWalletModal.ts @@ -4,23 +4,24 @@ import { } from '../../../../app/components/UI/DeleteWalletModal/DeleteWalletModal.testIds'; import Matchers from '../../../framework/Matchers'; import Gestures from '../../../framework/Gestures'; +import { EncapsulatedElementType } from '../../../framework'; class DeleteWalletModal { - get container(): DetoxElement { + get container(): EncapsulatedElementType { return Matchers.getElementByID(DeleteWalletModalSelectorsIDs.CONTAINER); } - get understandButton(): DetoxElement { + get understandButton(): EncapsulatedElementType { return Matchers.getElementByText( DeleteWalletModalSelectorsText.UNDERSTAND_BUTTON, ); } - get deleteWalletButton(): DetoxElement { + get deleteWalletButton(): EncapsulatedElementType { return Matchers.getElementByText(DeleteWalletModalSelectorsText.DELETE_MY); } - get deleteInput(): DetoxElement { + get deleteInput(): EncapsulatedElementType { return Matchers.getElementByID(DeleteWalletModalSelectorsIDs.INPUT); } diff --git a/tests/page-objects/Settings/SecurityAndPrivacy/RevealSecretRecoveryPhrase.ts b/tests/page-objects/Settings/SecurityAndPrivacy/RevealSecretRecoveryPhrase.ts index bed7260f302..2617249c153 100644 --- a/tests/page-objects/Settings/SecurityAndPrivacy/RevealSecretRecoveryPhrase.ts +++ b/tests/page-objects/Settings/SecurityAndPrivacy/RevealSecretRecoveryPhrase.ts @@ -5,21 +5,22 @@ import { import Matchers from '../../../framework/Matchers'; import Gestures from '../../../framework/Gestures'; import Utilities from '../../../framework/Utilities'; +import { EncapsulatedElementType } from '../../../framework'; class RevealSecretRecoveryPhrase { - get container(): DetoxElement { + get container(): EncapsulatedElementType { return Matchers.getElementByID( RevealSeedViewSelectorsIDs.REVEAL_CREDENTIAL_CONTAINER_ID, ); } - get passwordWarning(): DetoxElement { + get passwordWarning(): EncapsulatedElementType { return Matchers.getElementByID( RevealSeedViewSelectorsIDs.PASSWORD_WARNING_ID, ); } - get passwordInputToRevealCredential(): DetoxElement { + get passwordInputToRevealCredential(): EncapsulatedElementType { return Matchers.getElementByLabel( RevealSeedViewSelectorsIDs.PASSWORD_INPUT_BOX_ID, ); @@ -42,37 +43,37 @@ class RevealSecretRecoveryPhrase { ); } - get revealSecretRecoveryPhraseButton(): DetoxElement { + get revealSecretRecoveryPhraseButton(): EncapsulatedElementType { return Matchers.getElementByID( RevealSeedViewSelectorsIDs.REVEAL_CREDENTIAL_BUTTON_ID, ); } - get revealCredentialCopyToClipboardButton(): DetoxElement { + get revealCredentialCopyToClipboardButton(): EncapsulatedElementType { return Matchers.getElementByID( RevealSeedViewSelectorsIDs.REVEAL_CREDENTIAL_COPY_TO_CLIPBOARD_BUTTON, ); } - get revealCredentialQRCodeTab(): DetoxElement { + get revealCredentialQRCodeTab(): EncapsulatedElementType { return Matchers.getElementByText( RevealSeedViewSelectorsText.REVEAL_CREDENTIAL_QR_CODE_TAB_ID, ); } - get revealCredentialQRCodeImage(): DetoxElement { + get revealCredentialQRCodeImage(): EncapsulatedElementType { return Matchers.getElementByID( RevealSeedViewSelectorsIDs.REVEAL_CREDENTIAL_QR_CODE_IMAGE_ID, ); } - get doneButton(): DetoxElement { + get doneButton(): EncapsulatedElementType { return Matchers.getElementByText( RevealSeedViewSelectorsText.REVEAL_CREDENTIAL_DONE, ); } - get confirmButton(): DetoxElement { + get confirmButton(): EncapsulatedElementType { return Matchers.getElementByID( RevealSeedViewSelectorsIDs.SECRET_RECOVERY_PHRASE_NEXT_BUTTON_ID, ); diff --git a/tests/page-objects/Settings/SecurityAndPrivacy/SecurityAndPrivacyView.ts b/tests/page-objects/Settings/SecurityAndPrivacy/SecurityAndPrivacyView.ts index d6eb0a01aa7..c6a3053c657 100644 --- a/tests/page-objects/Settings/SecurityAndPrivacy/SecurityAndPrivacyView.ts +++ b/tests/page-objects/Settings/SecurityAndPrivacy/SecurityAndPrivacyView.ts @@ -4,32 +4,33 @@ import { } from '../../../../app/components/Views/Settings/SecuritySettings/SecurityPrivacyView.testIds'; import Matchers from '../../../framework/Matchers'; import Gestures from '../../../framework/Gestures'; +import { EncapsulatedElementType } from '../../../framework'; class SecurityAndPrivacy { - get changePasswordButton(): DetoxElement { + get changePasswordButton(): EncapsulatedElementType { return Matchers.getElementByID( SecurityPrivacyViewSelectorsIDs.CHANGE_PASSWORD_BUTTON, ); } - get revealSecretRecoveryPhraseButton(): DetoxElement { + get revealSecretRecoveryPhraseButton(): EncapsulatedElementType { return Matchers.getElementByID( SecurityPrivacyViewSelectorsIDs.REVEAL_SEED_BUTTON, ); } - get clearPrivacyDataButton(): DetoxElement { + get clearPrivacyDataButton(): EncapsulatedElementType { return Matchers.getElementByID( SecurityPrivacyViewSelectorsIDs.CLEAR_PRIVACY_DATA_BUTTON, ); } - get securityAndPrivacyHeading(): DetoxElement { + get securityAndPrivacyHeading(): EncapsulatedElementType { return Matchers.getElementByText( SecurityPrivacyViewSelectorsText.SECURITY_AND_PRIVACY_HEADING, ); } - get deleteWalletButton(): DetoxElement { + get deleteWalletButton(): EncapsulatedElementType { return device.getPlatform() === 'ios' ? Matchers.getElementByID( SecurityPrivacyViewSelectorsIDs.DELETE_WALLET_BUTTON, @@ -39,7 +40,7 @@ class SecurityAndPrivacy { ); } - get metaMetricsToggle(): DetoxElement { + get metaMetricsToggle(): EncapsulatedElementType { return Matchers.getElementByID( SecurityPrivacyViewSelectorsIDs.METAMETRICS_SWITCH, ); @@ -50,60 +51,60 @@ class SecurityAndPrivacy { ); } - get autoLockSection(): DetoxElement { + get autoLockSection(): EncapsulatedElementType { return Matchers.getElementByID( SecurityPrivacyViewSelectorsIDs.AUTO_LOCK_SECTION, ); } - get autoLockDefault30Seconds(): DetoxElement { + get autoLockDefault30Seconds(): EncapsulatedElementType { return Matchers.getElementByText( SecurityPrivacyViewSelectorsText.AUTO_LOCK_30_SECONDS, ); } - get rememberMeToggle(): DetoxElement { + get rememberMeToggle(): EncapsulatedElementType { return Matchers.getElementByID( SecurityPrivacyViewSelectorsIDs.REMEMBER_ME_TOGGLE, ); } - get changePasswordSection(): DetoxElement { + get changePasswordSection(): EncapsulatedElementType { return Matchers.getElementByID( SecurityPrivacyViewSelectorsIDs.CHANGE_PASSWORD_CONTAINER, ); } - get securitySettingsScroll(): DetoxElement { + get securitySettingsScroll(): EncapsulatedElementType { return Matchers.getElementByID( SecurityPrivacyViewSelectorsIDs.SECURITY_SETTINGS_SCROLL, ); } - get showPrivateKey(): DetoxElement { + get showPrivateKey(): EncapsulatedElementType { return Matchers.getElementByText( SecurityPrivacyViewSelectorsText.SHOW_PRIVATE_KEY, ); } - get showPrivateKeyButton(): DetoxElement { + get showPrivateKeyButton(): EncapsulatedElementType { return Matchers.getElementByText( SecurityPrivacyViewSelectorsText.SHOW_PRIVATE_KEY, ); } - get backUpNow(): DetoxElement { + get backUpNow(): EncapsulatedElementType { return Matchers.getElementByText( SecurityPrivacyViewSelectorsText.BACK_UP_NOW, ); } - get privacyHeader(): DetoxElement { + get privacyHeader(): EncapsulatedElementType { return Matchers.getElementByText( SecurityPrivacyViewSelectorsText.PRIVACY_HEADING, ); } - get clearBrowserCookiesButton(): DetoxElement { + get clearBrowserCookiesButton(): EncapsulatedElementType { return Matchers.getElementByText( SecurityPrivacyViewSelectorsText.CLEAR_BROWSER_COOKIES, ); diff --git a/tests/page-objects/Settings/SecurityAndPrivacy/SrpQuizModal.ts b/tests/page-objects/Settings/SecurityAndPrivacy/SrpQuizModal.ts index 01da2edd8fa..94eb70032b8 100644 --- a/tests/page-objects/Settings/SecurityAndPrivacy/SrpQuizModal.ts +++ b/tests/page-objects/Settings/SecurityAndPrivacy/SrpQuizModal.ts @@ -8,24 +8,25 @@ import { } from '../../../../app/components/Views/Quiz/SRPQuiz/SrpQuizModal.testIds'; import Matchers from '../../../framework/Matchers'; import Gestures from '../../../framework/Gestures'; +import { EncapsulatedElementType } from '../../../framework'; class SrpQuizModal { // Getters for common elements - get getStartedContainer(): DetoxElement { + get getStartedContainer(): EncapsulatedElementType { return Matchers.getElementByID(SrpQuizGetStartedSelectorsIDs.CONTAINER); } - get getStartedScreenDismiss(): DetoxElement { + get getStartedScreenDismiss(): EncapsulatedElementType { return Matchers.getElementByID(SrpQuizGetStartedSelectorsIDs.DISMISS); } - get modalIntroduction(): DetoxElement { + get modalIntroduction(): EncapsulatedElementType { return Matchers.getElementByText( SrpQuizGetStartedSelectorsText.INTRODUCTION, ); } - get getStartedButton(): DetoxElement { + get getStartedButton(): EncapsulatedElementType { return Matchers.getElementByID(SrpQuizGetStartedSelectorsIDs.BUTTON); } diff --git a/tests/page-objects/Settings/SettingsView.ts b/tests/page-objects/Settings/SettingsView.ts index 4dbcfee4333..3ff60132d34 100644 --- a/tests/page-objects/Settings/SettingsView.ts +++ b/tests/page-objects/Settings/SettingsView.ts @@ -5,60 +5,61 @@ import { SettingsViewSelectorsText, } from '../../../app/components/Views/Settings/SettingsView.testIds'; import { CommonSelectorsText } from '../../../app/util/Common.testIds'; +import { EncapsulatedElementType } from '../../framework'; class SettingsView { - get title(): DetoxElement { + get title(): EncapsulatedElementType { return Matchers.getElementByText(SettingsViewSelectorsText.TITLE); } - get generalSettingsButton(): DetoxElement { + get generalSettingsButton(): EncapsulatedElementType { return Matchers.getElementByID(SettingsViewSelectorsIDs.GENERAL); } - get advancedButton(): DetoxElement { + get advancedButton(): EncapsulatedElementType { return Matchers.getElementByID(SettingsViewSelectorsIDs.ADVANCED); } - get contactsSettingsButton(): DetoxElement { + get contactsSettingsButton(): EncapsulatedElementType { return Matchers.getElementByID(SettingsViewSelectorsIDs.CONTACTS); } - get securityAndPrivacyButton(): DetoxElement { + get securityAndPrivacyButton(): EncapsulatedElementType { return Matchers.getElementByID(SettingsViewSelectorsIDs.SECURITY); } - get notificationsButton(): DetoxElement { + get notificationsButton(): EncapsulatedElementType { return Matchers.getElementByID(SettingsViewSelectorsIDs.NOTIFICATIONS); } - get aesCryptoTestForm(): DetoxElement { + get aesCryptoTestForm(): EncapsulatedElementType { return Matchers.getElementByID( SettingsViewSelectorsIDs.AES_CRYPTO_TEST_FORM, ); } - get lockSettingsButton(): DetoxElement { + get lockSettingsButton(): EncapsulatedElementType { return Matchers.getElementByID(SettingsViewSelectorsIDs.LOCK); } - get contactSupportButton(): DetoxElement { + get contactSupportButton(): EncapsulatedElementType { return Matchers.getElementByID(SettingsViewSelectorsIDs.CONTACT); } - get contactSupportSectionTitle(): DetoxElement { + get contactSupportSectionTitle(): EncapsulatedElementType { return Matchers.getElementByText( SettingsViewSelectorsText.CONTACT_SUPPORT_TITLE, ); } - get backupAndSyncSectionButton(): DetoxElement { + get backupAndSyncSectionButton(): EncapsulatedElementType { return Matchers.getElementByID(SettingsViewSelectorsIDs.BACKUP_AND_SYNC); } - get snapsSectionButton(): DetoxElement { + get snapsSectionButton(): EncapsulatedElementType { return Matchers.getElementByID(SettingsViewSelectorsIDs.SNAPS); } - get alertButton(): DetoxElement { + get alertButton(): EncapsulatedElementType { return device.getPlatform() === 'android' ? Matchers.getElementByText(CommonSelectorsText.YES_ALERT_BUTTON) : Matchers.getElementByLabel(CommonSelectorsText.YES_ALERT_BUTTON); @@ -194,7 +195,7 @@ class SettingsView { }); } - get backButton(): DetoxElement { + get backButton(): EncapsulatedElementType { return Matchers.getElementByID(SettingsViewSelectorsIDs.BACK_BUTTON); } diff --git a/tests/page-objects/Settings/SnapSettingsView.ts b/tests/page-objects/Settings/SnapSettingsView.ts index e5e9c28aa0c..0a3376484be 100644 --- a/tests/page-objects/Settings/SnapSettingsView.ts +++ b/tests/page-objects/Settings/SnapSettingsView.ts @@ -1,12 +1,13 @@ import Matchers from '../../framework/Matchers'; import Gestures from '../../framework/Gestures'; +import { EncapsulatedElementType } from '../../framework'; class SnapSettingsView { - get enabledToggle(): DetoxElement { + get enabledToggle(): EncapsulatedElementType { return Matchers.getElementByID('snap-details-switch'); } - get removeButton(): DetoxElement { + get removeButton(): EncapsulatedElementType { return Matchers.getElementByID('snap-settings-remove-button'); } @@ -14,11 +15,11 @@ class SnapSettingsView { return Matchers.getIdentifier('snap-settings-scrollview'); } - get backButton(): DetoxElement { + get backButton(): EncapsulatedElementType { return Matchers.getElementByID('snap-settings-back-button'); } - get listBackButton(): DetoxElement { + get listBackButton(): EncapsulatedElementType { return Matchers.getElementByID('snaps-settings-list-back-button'); } diff --git a/tests/page-objects/Stake/StakeConfirmView.ts b/tests/page-objects/Stake/StakeConfirmView.ts index 524abdc493f..46f0ed67851 100644 --- a/tests/page-objects/Stake/StakeConfirmView.ts +++ b/tests/page-objects/Stake/StakeConfirmView.ts @@ -1,9 +1,10 @@ import Gestures from '../../framework/Gestures'; import Matchers from '../../framework/Matchers'; import { StakeConfirmViewSelectors } from '../../selectors/Stake/StakeConfirmView.selectors.js'; +import { EncapsulatedElementType } from '../../framework'; class StakeConfirmationView { - get confirmButton(): DetoxElement { + get confirmButton(): EncapsulatedElementType { return Matchers.getElementByText(StakeConfirmViewSelectors.CONFIRM); } diff --git a/tests/page-objects/Stake/StakeView.ts b/tests/page-objects/Stake/StakeView.ts index 269ebc50098..19966d07ee0 100644 --- a/tests/page-objects/Stake/StakeView.ts +++ b/tests/page-objects/Stake/StakeView.ts @@ -2,21 +2,22 @@ import { StakeViewSelectors } from '../../selectors/Stake/StakeView.selectors.js import Matchers from '../../framework/Matchers'; import Gestures from '../../framework/Gestures'; import Utilities from '../../framework/Utilities'; +import { EncapsulatedElementType } from '../../framework'; class StakeView { - get stakeContainer(): DetoxElement { + get stakeContainer(): EncapsulatedElementType { return Matchers.getElementByText(StakeViewSelectors.STAKE_CONTAINER); } - get unstakeContainer(): DetoxElement { + get unstakeContainer(): EncapsulatedElementType { return Matchers.getElementByText(StakeViewSelectors.UNSTAKE_CONTAINER); } - get reviewButton(): DetoxElement { + get reviewButton(): EncapsulatedElementType { return Matchers.getElementByText(StakeViewSelectors.REVIEW_BUTTON); } - get confirmButton(): DetoxElement { + get confirmButton(): EncapsulatedElementType { return Matchers.getElementByText(StakeViewSelectors.CONFIRM); } diff --git a/tests/page-objects/Transactions/ActivitiesView.ts b/tests/page-objects/Transactions/ActivitiesView.ts index 0d79b3d30bb..73dadd6942e 100644 --- a/tests/page-objects/Transactions/ActivitiesView.ts +++ b/tests/page-objects/Transactions/ActivitiesView.ts @@ -12,88 +12,89 @@ import Matchers from '../../framework/Matchers'; import Gestures from '../../framework/Gestures'; import Assertions from '../../framework/Assertions'; import { encapsulatedAction } from '../../framework/encapsulatedAction'; +import { EncapsulatedElementType } from '../../framework/EncapsulatedElement'; import PlaywrightMatchers from '../../framework/PlaywrightMatchers'; import PlaywrightAssertions from '../../framework/PlaywrightAssertions'; class ActivitiesView { - get title(): DetoxElement { + get title(): EncapsulatedElementType { return Matchers.getElementByText(ActivitiesViewSelectorsText.TITLE); } - get predictionsTab(): DetoxElement { + get predictionsTab(): EncapsulatedElementType { return Matchers.getElementByLabel( ActivitiesViewSelectorsText.PREDICTIONS_TAB, ); } - get transferTab(): DetoxElement { + get transferTab(): EncapsulatedElementType { return Matchers.getElementByID(ActivitiesViewSelectorsIDs.TRANSFER_TAB); } - get tabsBar(): DetoxElement { + get tabsBar(): EncapsulatedElementType { return Matchers.getElementByID( `${ActivitiesViewSelectorsIDs.TABS_CONTAINER}-bar`, ); } - get container(): DetoxElement { + get container(): EncapsulatedElementType { return Matchers.getElementByID(ActivitiesViewSelectorsIDs.CONTAINER); } - get confirmedLabel(): DetoxElement { + get confirmedLabel(): EncapsulatedElementType { return Matchers.getElementByText(ActivitiesViewSelectorsText.CONFIRM_TEXT); } - get stakeDepositedLabel(): DetoxElement { + get stakeDepositedLabel(): EncapsulatedElementType { return Matchers.getElementByText(ActivitiesViewSelectorsText.STAKE_DEPOSIT); } - get stakeMoreDepositedLabel(): DetoxElement { + get stakeMoreDepositedLabel(): EncapsulatedElementType { return Matchers.getElementByText( ActivitiesViewSelectorsText.STAKE_DEPOSIT, 0, ); } - get unstakeLabel(): DetoxElement { + get unstakeLabel(): EncapsulatedElementType { return Matchers.getElementByText(ActivitiesViewSelectorsText.UNSTAKE); } - get stackingClaimLabel(): DetoxElement { + get stackingClaimLabel(): EncapsulatedElementType { return Matchers.getElementByText(ActivitiesViewSelectorsText.STAKING_CLAIM); } - get approveActivity(): DetoxElement { + get approveActivity(): EncapsulatedElementType { return Matchers.getElementByText(ActivitiesViewSelectorsText.APPROVE); } - get lendingDepositActivity(): DetoxElement { + get lendingDepositActivity(): EncapsulatedElementType { return Matchers.getElementByText( ActivitiesViewSelectorsText.LENDING_DEPOSIT, ); } - get lendingWithdrawalActivity(): DetoxElement { + get lendingWithdrawalActivity(): EncapsulatedElementType { return Matchers.getElementByText( ActivitiesViewSelectorsText.LENDING_WITHDRAWAL, ); } - get predictDeposit(): DetoxElement { + get predictDeposit(): EncapsulatedElementType { return Matchers.getElementByText( ActivitiesViewSelectorsText.PREDICT_DEPOSIT, ); } - get predictWithdraw(): DetoxElement { + get predictWithdraw(): EncapsulatedElementType { return Matchers.getElementByText( ActivitiesViewSelectorsText.PREDICT_WITHDRAW, ); } - transactionStatus(row: number): DetoxElement { + transactionStatus(row: number): EncapsulatedElementType { return Matchers.getElementByID(`transaction-status-${row}`); } - transactionItem(row: number): DetoxElement { + transactionItem(row: number): EncapsulatedElementType { return Matchers.getElementByID(`transaction-item-${row}`); } @@ -116,17 +117,17 @@ class ActivitiesView { swapActivityTitle( sourceToken: string, destinationToken: string, - ): DetoxElement { + ): EncapsulatedElementType { return Matchers.getElementByText( this.generateSwapActivityLabel(sourceToken, destinationToken), ); } - swapApprovalActivityTitle(): DetoxElement { + swapApprovalActivityTitle(): EncapsulatedElementType { return Matchers.getElementByText(ActivitiesViewSelectorsText.APPROVE); } - bridgeActivityTitle(destNetwork: string): DetoxElement { + bridgeActivityTitle(destNetwork: string): EncapsulatedElementType { return Matchers.getElementByText( this.generateBridgeActivityLabel(destNetwork), ); @@ -184,7 +185,7 @@ class ActivitiesView { rampsOrderCryptoAmount( orderType: RampsOrderTypeSlug, rowIndex: number, - ): DetoxElement { + ): EncapsulatedElementType { return Matchers.getElementByID( getOrderRowCryptoAmountTestId(orderType, rowIndex), ); @@ -193,7 +194,7 @@ class ActivitiesView { rampsOrderFiatAmount( orderType: RampsOrderTypeSlug, rowIndex: number, - ): DetoxElement { + ): EncapsulatedElementType { return Matchers.getElementByID( getOrderRowFiatAmountTestId(orderType, rowIndex), ); diff --git a/tests/page-objects/Transactions/AssetWatchBottomSheet.ts b/tests/page-objects/Transactions/AssetWatchBottomSheet.ts index 0e437df240f..bc59fb9b34e 100644 --- a/tests/page-objects/Transactions/AssetWatchBottomSheet.ts +++ b/tests/page-objects/Transactions/AssetWatchBottomSheet.ts @@ -1,16 +1,17 @@ import { AssetWatcherSelectorsIDs } from '../../../app/components/Views/confirmations/legacy/components/WatchAssetRequest/AssetWatcher.testIds'; import Matchers from '../../framework/Matchers'; import Gestures from '../../framework/Gestures'; +import { EncapsulatedElementType } from '../../framework'; class AssetWatchBottomSheet { - get container(): DetoxElement { + get container(): EncapsulatedElementType { return Matchers.getElementByID(AssetWatcherSelectorsIDs.CONTAINER); } - get cancelButton(): DetoxElement { + get cancelButton(): EncapsulatedElementType { return Matchers.getElementByID(AssetWatcherSelectorsIDs.CANCEL_BUTTON); } - get confirmButton(): DetoxElement { + get confirmButton(): EncapsulatedElementType { return Matchers.getElementByID(AssetWatcherSelectorsIDs.CONFIRM_BUTTON); } diff --git a/tests/page-objects/Transactions/TransactionDetailsModal.ts b/tests/page-objects/Transactions/TransactionDetailsModal.ts index fd245259e4f..2863f90f33a 100644 --- a/tests/page-objects/Transactions/TransactionDetailsModal.ts +++ b/tests/page-objects/Transactions/TransactionDetailsModal.ts @@ -5,38 +5,38 @@ import { } from '../../../app/components/Views/confirmations/components/activity/TransactionDetailsModal.testIds'; import Matchers from '../../framework/Matchers'; import Gestures from '../../framework/Gestures'; -import { Assertions, logger } from '../../framework'; +import { Assertions, logger, EncapsulatedElementType } from '../../framework'; class TransactionDetailsModal { - get closeIcon(): DetoxElement { + get closeIcon(): EncapsulatedElementType { return Matchers.getElementByID( TransactionDetailsModalSelectorsIDs.CLOSE_ICON, ); } - get networkFee(): DetoxElement { + get networkFee(): EncapsulatedElementType { return Matchers.getElementByID(TransactionDetailsSelectorIDs.NETWORK_FEE); } - get paidWithSymbol(): DetoxElement { + get paidWithSymbol(): EncapsulatedElementType { return Matchers.getElementByID( TransactionDetailsSelectorIDs.PAID_WITH_SYMBOL, ); } - get status(): DetoxElement { + get status(): EncapsulatedElementType { return Matchers.getElementByID(TransactionDetailsSelectorIDs.STATUS); } - get title(): DetoxElement { + get title(): EncapsulatedElementType { return Matchers.getElementByID(TransactionDetailsModalSelectorsIDs.TITLE); } - get total(): DetoxElement { + get total(): EncapsulatedElementType { return Matchers.getElementByID(TransactionDetailsSelectorIDs.TOTAL); } - get transactionFee(): DetoxElement { + get transactionFee(): EncapsulatedElementType { return Matchers.getElementByID( TransactionDetailsSelectorIDs.TRANSACTION_FEE, ); diff --git a/tests/page-objects/Transactions/UnifiedTransactionsView.ts b/tests/page-objects/Transactions/UnifiedTransactionsView.ts index 36d8a31ce44..1f30458dd7b 100644 --- a/tests/page-objects/Transactions/UnifiedTransactionsView.ts +++ b/tests/page-objects/Transactions/UnifiedTransactionsView.ts @@ -1,9 +1,10 @@ import Matchers from '../../framework/Matchers'; import { UnifiedTransactionsViewSelectorsIDs } from '../../../app/components/Views/UnifiedTransactionsView/UnifiedTransactionsView.testIds'; import Gestures from '../../framework/Gestures'; +import { EncapsulatedElementType } from '../../framework'; class UnifiedTransactionsView { - get container(): DetoxElement { + get container(): EncapsulatedElementType { return Matchers.getElementByID( UnifiedTransactionsViewSelectorsIDs.CONTAINER, ); diff --git a/tests/page-objects/Transactions/predictionsActivityDetails.ts b/tests/page-objects/Transactions/predictionsActivityDetails.ts index 532b4af0a59..41e47489d3e 100644 --- a/tests/page-objects/Transactions/predictionsActivityDetails.ts +++ b/tests/page-objects/Transactions/predictionsActivityDetails.ts @@ -1,21 +1,22 @@ import { PredictActivityDetailsSelectorsIDs } from '../../../app/components/UI/Predict/Predict.testIds'; import Matchers from '../../framework/Matchers'; import Gestures from '../../framework/Gestures'; +import { EncapsulatedElementType } from '../../framework'; class PredictActivityDetails { - get container(): DetoxElement { + get container(): EncapsulatedElementType { return Matchers.getElementByID( PredictActivityDetailsSelectorsIDs.CONTAINER, ); } - get backButton(): DetoxElement { + get backButton(): EncapsulatedElementType { return Matchers.getElementByID( PredictActivityDetailsSelectorsIDs.BACK_BUTTON, ); } - get amountDisplay(): DetoxElement { + get amountDisplay(): EncapsulatedElementType { return Matchers.getElementByID( PredictActivityDetailsSelectorsIDs.AMOUNT_DISPLAY, ); diff --git a/tests/page-objects/Trending/TrendingView.ts b/tests/page-objects/Trending/TrendingView.ts index 48877e9a798..7bc8dd2e4f1 100644 --- a/tests/page-objects/Trending/TrendingView.ts +++ b/tests/page-objects/Trending/TrendingView.ts @@ -3,6 +3,7 @@ import { Gestures, Assertions, type ScrollOptions, + EncapsulatedElementType, } from '../../framework'; import { TrendingViewSelectorsIDs, @@ -18,80 +19,80 @@ import BrowserView from '../Browser/BrowserView'; class TrendingView { private activeScrollViewID: string = TrendingViewSelectorsIDs.NOW_SCROLL_VIEW; - get searchButton(): DetoxElement { + get searchButton(): EncapsulatedElementType { return Matchers.getElementByID(TrendingViewSelectorsIDs.SEARCH_BUTTON); } - get browserButton(): DetoxElement { + get browserButton(): EncapsulatedElementType { return Matchers.getElementByID(TrendingViewSelectorsIDs.BROWSER_BUTTON); } - get searchInputContainer(): DetoxElement { + get searchInputContainer(): EncapsulatedElementType { return Matchers.getElementByID(TrendingViewSelectorsIDs.SEARCH_INPUT); } - get searchInput(): DetoxElement { + get searchInput(): EncapsulatedElementType { return Matchers.getElementByID(TrendingViewSelectorsIDs.SEARCH_TEXT_INPUT); } - get searchCancelButton(): DetoxElement { + get searchCancelButton(): EncapsulatedElementType { return Matchers.getElementByID( TrendingViewSelectorsIDs.SEARCH_CANCEL_BUTTON, ); } - get searchAllPill(): DetoxElement { + get searchAllPill(): EncapsulatedElementType { return Matchers.getElementByID(TrendingViewSelectorsIDs.SEARCH_PILL_ALL); } - get searchCryptosPill(): DetoxElement { + get searchCryptosPill(): EncapsulatedElementType { return Matchers.getElementByID( TrendingViewSelectorsIDs.SEARCH_PILL_CRYPTOS, ); } - get searchResultsList(): DetoxElement { + get searchResultsList(): EncapsulatedElementType { return Matchers.getElementByID( TrendingViewSelectorsIDs.SEARCH_RESULTS_LIST, ); } - getTokenRow(assetId: string): DetoxElement { + getTokenRow(assetId: string): EncapsulatedElementType { return Matchers.getElementByID( `${TrendingViewSelectorsIDs.TOKEN_ROW_ITEM_PREFIX}${assetId}`, 0, ); } - getPerpRow(symbol: string): DetoxElement { + getPerpRow(symbol: string): EncapsulatedElementType { return Matchers.getElementByID( `${TrendingViewSelectorsIDs.PERPS_ROW_ITEM_PREFIX}${symbol}`, 0, ); } - getPredictionRow(id: string): DetoxElement { + getPredictionRow(id: string): EncapsulatedElementType { return Matchers.getElementByID( `${TrendingViewSelectorsIDs.PREDICTIONS_ROW_ITEM_PREFIX}${id}`, 0, ); } - getSiteRow(name: string): DetoxElement { + getSiteRow(name: string): EncapsulatedElementType { return Matchers.getElementByID( `${TrendingViewSelectorsIDs.SITE_ROW_ITEM_PREFIX}${name}`, 0, ); } - getSectionHeader(title: string): DetoxElement { + getSectionHeader(title: string): EncapsulatedElementType { return Matchers.getElementByText(title); } /** * Get section header by testID (for full view headers) */ - getSectionHeaderByTestID(title: string): DetoxElement | null { + getSectionHeaderByTestID(title: string): EncapsulatedElementType | null { const headerTestID = SECTION_FULL_VIEW_HEADERS[title]; if (!headerTestID) { return null; @@ -99,7 +100,7 @@ class TrendingView { return Matchers.getElementByID(headerTestID); } - getBackButton(testID: string): DetoxElement { + getBackButton(testID: string): EncapsulatedElementType { return Matchers.getElementByID(testID); } @@ -166,7 +167,7 @@ class TrendingView { * Uses Gestures.scrollToElement which retries scroll + visibility check until the element is on screen. */ private async scrollToElementInFeed( - targetElement: DetoxElement, + targetElement: EncapsulatedElementType, description: string, direction: 'up' | 'down' = 'down', options: Partial = {}, @@ -285,7 +286,7 @@ class TrendingView { * @param itemType - Type of item for description ('token', 'perp', 'prediction', 'site') */ private async verifyItemVisible( - getElement: () => DetoxElement, + getElement: () => EncapsulatedElementType, identifier: string, itemType: string, options: Partial = {}, @@ -309,7 +310,7 @@ class TrendingView { * Gestures.scrollToElement retries scroll until the element is visible. */ private async tapItemRow( - getElement: () => DetoxElement, + getElement: () => EncapsulatedElementType, identifier: string, itemType: string, ): Promise { diff --git a/tests/page-objects/UI/FundActionMenu.ts b/tests/page-objects/UI/FundActionMenu.ts index b6217abd73d..cb99ac1fbc8 100644 --- a/tests/page-objects/UI/FundActionMenu.ts +++ b/tests/page-objects/UI/FundActionMenu.ts @@ -1,27 +1,28 @@ import { WalletActionsBottomSheetSelectorsIDs } from '../../../app/components/Views/WalletActions/WalletActionsBottomSheet.testIds'; import Matchers from '../../framework/Matchers'; import Gestures from '../../framework/Gestures'; +import { EncapsulatedElementType } from '../../framework'; class FundActionMenu { - get depositButton(): DetoxElement { + get depositButton(): EncapsulatedElementType { return Matchers.getElementByID( WalletActionsBottomSheetSelectorsIDs.DEPOSIT_BUTTON, ); } - get buyButton(): DetoxElement { + get buyButton(): EncapsulatedElementType { return Matchers.getElementByID( WalletActionsBottomSheetSelectorsIDs.BUY_BUTTON, ); } - get unifiedBuyButton(): DetoxElement { + get unifiedBuyButton(): EncapsulatedElementType { return Matchers.getElementByID( WalletActionsBottomSheetSelectorsIDs.BUY_UNIFIED_BUTTON, ); } - get sellButton(): DetoxElement { + get sellButton(): EncapsulatedElementType { return Matchers.getElementByID( WalletActionsBottomSheetSelectorsIDs.SELL_BUTTON, ); diff --git a/tests/page-objects/importAccount/ImportAccountView.ts b/tests/page-objects/importAccount/ImportAccountView.ts index b1de4140dbe..5f9e0bb1851 100644 --- a/tests/page-objects/importAccount/ImportAccountView.ts +++ b/tests/page-objects/importAccount/ImportAccountView.ts @@ -1,19 +1,20 @@ import Matchers from '../../framework/Matchers'; import Gestures from '../../framework/Gestures'; +import { EncapsulatedElementType } from '../../framework/EncapsulatedElement'; import { ImportAccountFromPrivateKeyIDs } from '../../../app/components/Views/ImportPrivateKey/ImportAccountFromPrivateKey.testIds'; class ImportAccountView { - get container(): DetoxElement { + get container(): EncapsulatedElementType { return Matchers.getElementByID(ImportAccountFromPrivateKeyIDs.CONTAINER); } - get importButton(): DetoxElement { + get importButton(): EncapsulatedElementType { return Matchers.getElementByID( ImportAccountFromPrivateKeyIDs.IMPORT_BUTTON, ); } - get privateKeyField(): DetoxElement { + get privateKeyField(): EncapsulatedElementType { return Matchers.getElementByID( ImportAccountFromPrivateKeyIDs.PRIVATE_KEY_INPUT_BOX, ); diff --git a/tests/page-objects/importAccount/SuccessImportAccountView.ts b/tests/page-objects/importAccount/SuccessImportAccountView.ts index a1ac25e5031..a06fa7015ab 100644 --- a/tests/page-objects/importAccount/SuccessImportAccountView.ts +++ b/tests/page-objects/importAccount/SuccessImportAccountView.ts @@ -2,14 +2,18 @@ import Matchers from '../../framework/Matchers'; import Gestures from '../../framework/Gestures'; import { SuccessImportAccountIDs } from '../../../app/components/Views/ImportPrivateKeySuccess/SuccessImportAccount.testIds'; import WalletView from '../wallet/WalletView'; -import { asDetoxElement, Utilities } from '../../framework'; +import { + asDetoxElement, + Utilities, + EncapsulatedElementType, +} from '../../framework'; class SuccessImportAccountView { - get container(): DetoxElement { + get container(): EncapsulatedElementType { return Matchers.getElementByID(SuccessImportAccountIDs.CONTAINER); } - get closeButton(): DetoxElement { + get closeButton(): EncapsulatedElementType { return Matchers.getElementByID(SuccessImportAccountIDs.CLOSE_BUTTON); } diff --git a/tests/page-objects/importSrp/ImportSrpView.ts b/tests/page-objects/importSrp/ImportSrpView.ts index 8c1d85ebff6..1695e45f80b 100644 --- a/tests/page-objects/importSrp/ImportSrpView.ts +++ b/tests/page-objects/importSrp/ImportSrpView.ts @@ -1,25 +1,26 @@ import Matchers from '../../framework/Matchers'; import Gestures from '../../framework/Gestures'; +import { EncapsulatedElementType } from '../../framework/EncapsulatedElement'; import { ImportSRPIDs } from '../../../app/components/Views/ImportNewSecretRecoveryPhrase/SRPImport.testIds'; class ImportSrpView { - get container(): DetoxElement { + get container(): EncapsulatedElementType { return Matchers.getElementByID(ImportSRPIDs.CONTAINER); } - get title(): DetoxElement { + get title(): EncapsulatedElementType { return Matchers.getElementByID(ImportSRPIDs.SCREEN_TITLE_ID); } - get importButton(): DetoxElement { + get importButton(): EncapsulatedElementType { return Matchers.getElementByID(ImportSRPIDs.IMPORT_BUTTON); } - get textareaInput(): DetoxElement { + get textareaInput(): EncapsulatedElementType { return Matchers.getElementByID(ImportSRPIDs.SEED_PHRASE_INPUT_ID); } - seedPhraseInput(index: number): DetoxElement { + seedPhraseInput(index: number): EncapsulatedElementType { if (index !== 0) { return Matchers.getElementByID( `${ImportSRPIDs.SEED_PHRASE_INPUT_ID}_${index}`, diff --git a/tests/page-objects/swaps/Deeplink.ts b/tests/page-objects/swaps/Deeplink.ts index 49c16279ae1..fec1533bf12 100644 --- a/tests/page-objects/swaps/Deeplink.ts +++ b/tests/page-objects/swaps/Deeplink.ts @@ -1,12 +1,13 @@ import Matchers from '../../framework/Matchers'; import Gestures from '../../framework/Gestures'; +import { EncapsulatedElementType } from '../../framework'; class DeeplinkModal { - get continueButton(): DetoxElement { + get continueButton(): EncapsulatedElementType { return Matchers.getElementByText('Continue'); } - get proceedWithCaution(): DetoxElement { + get proceedWithCaution(): EncapsulatedElementType { return Matchers.getElementByText('Proceed with caution'); } diff --git a/tests/page-objects/swaps/OnBoarding.ts b/tests/page-objects/swaps/OnBoarding.ts index 05a1981e242..01e3f2dc430 100644 --- a/tests/page-objects/swaps/OnBoarding.ts +++ b/tests/page-objects/swaps/OnBoarding.ts @@ -1,9 +1,10 @@ import Matchers from '../../framework/Matchers'; import Gestures from '../../framework/Gestures'; import { OnBoardingSelectors } from '../../selectors/swaps/OnBoarding.selectors'; +import { EncapsulatedElementType } from '../../framework'; class Onboarding { - get startSwappingButton(): DetoxElement { + get startSwappingButton(): EncapsulatedElementType { return Matchers.getElementByText(OnBoardingSelectors.START_SWAPPING); } diff --git a/tests/page-objects/swaps/QuoteView.ts b/tests/page-objects/swaps/QuoteView.ts index a7120810c3a..da417029875 100644 --- a/tests/page-objects/swaps/QuoteView.ts +++ b/tests/page-objects/swaps/QuoteView.ts @@ -32,19 +32,19 @@ const TIMEOUT = { } as const; class QuoteView { - get selectAmountLabel(): DetoxElement { + get selectAmountLabel(): EncapsulatedElementType { return Matchers.getElementByText(QuoteViewSelectorText.SELECT_AMOUNT); } - get confirmBridge(): DetoxElement { + get confirmBridge(): EncapsulatedElementType { return Matchers.getElementByID(QuoteViewSelectorIDs.CONFIRM_BUTTON); } - get confirmSwap(): DetoxElement { + get confirmSwap(): EncapsulatedElementType { return Matchers.getElementByID(QuoteViewSelectorIDs.CONFIRM_BUTTON); } - get sourceTokenArea(): DetoxElement { + get sourceTokenArea(): EncapsulatedElementType { return Matchers.getElementByID(QuoteViewSelectorIDs.SOURCE_TOKEN_AREA); } @@ -96,15 +96,15 @@ class QuoteView { }); } - get seeAllButton(): DetoxElement { + get seeAllButton(): EncapsulatedElementType { return Matchers.getElementByText(QuoteViewSelectorText.SELECT_ALL); } - get backButton(): DetoxElement { + get backButton(): EncapsulatedElementType { return Matchers.getElementByID(QuoteViewSelectorIDs.BACK_BUTTON); } - get networkFeeLabel(): DetoxElement { + get networkFeeLabel(): EncapsulatedElementType { return Matchers.getElementByText(QuoteViewSelectorText.NETWORK_FEE); } @@ -149,15 +149,15 @@ class QuoteView { }); } - get maxLink(): DetoxElement { + get maxLink(): EncapsulatedElementType { return Matchers.getElementByText(QuoteViewSelectorText.MAX); } - get includedLabel(): DetoxElement { + get includedLabel(): EncapsulatedElementType { return Matchers.getElementByText(QuoteViewSelectorText.INCLUDED); } - get rateLabel(): DetoxElement { + get rateLabel(): EncapsulatedElementType { return Matchers.getElementByText(QuoteViewSelectorText.RATE); } @@ -509,7 +509,7 @@ class QuoteView { * Gets the slippage display text element (e.g., "2.5%") * @param value - The slippage value to match (e.g., "2.5" for 2.5%) */ - slippageDisplayText(value: string): DetoxElement { + slippageDisplayText(value: string): EncapsulatedElementType { return Matchers.getElementByText(`${value}%`); } diff --git a/tests/page-objects/swaps/SlippageModal.ts b/tests/page-objects/swaps/SlippageModal.ts index 28f1f37dc62..538d4dc51ee 100644 --- a/tests/page-objects/swaps/SlippageModal.ts +++ b/tests/page-objects/swaps/SlippageModal.ts @@ -5,29 +5,30 @@ import { SlippageModalSelectorIDs, SlippageModalSelectorText, } from '../../selectors/Bridge/SlippageModal.selectors'; +import { EncapsulatedElementType } from '../../framework'; class SlippageModal { - get editSlippageButton(): DetoxElement { + get editSlippageButton(): EncapsulatedElementType { return Matchers.getElementByID( SlippageModalSelectorIDs.EDIT_SLIPPAGE_BUTTON, ); } - get customButton(): DetoxElement { + get customButton(): EncapsulatedElementType { return Matchers.getElementByText(SlippageModalSelectorText.CUSTOM); } - get confirmButton(): DetoxElement { + get confirmButton(): EncapsulatedElementType { return Matchers.getElementByText(SlippageModalSelectorText.CONFIRM); } - get inputStepperInput(): DetoxElement { + get inputStepperInput(): EncapsulatedElementType { return Matchers.getElementByID( SlippageModalSelectorIDs.INPUT_STEPPER_INPUT, ); } - get keypadDeleteButton(): DetoxElement { + get keypadDeleteButton(): EncapsulatedElementType { return Matchers.getElementByID( SlippageModalSelectorIDs.KEYPAD_DELETE_BUTTON, ); diff --git a/tests/page-objects/swaps/SwapTrendingTokensView.ts b/tests/page-objects/swaps/SwapTrendingTokensView.ts index 3e29c07fe7c..132f06ea674 100644 --- a/tests/page-objects/swaps/SwapTrendingTokensView.ts +++ b/tests/page-objects/swaps/SwapTrendingTokensView.ts @@ -1,4 +1,9 @@ -import { Assertions, Gestures, Matchers } from '../../framework'; +import { + Assertions, + Gestures, + Matchers, + EncapsulatedElementType, +} from '../../framework'; import { BridgeViewSelectorsIDs } from '../../../app/components/UI/Bridge/Views/BridgeView/BridgeView.testIds'; import { BridgeTrendingTokensSectionTestIds } from '../../../app/components/UI/Bridge/components/BridgeTrendingTokensSection/BridgeTrendingTokensSection.testIds'; @@ -13,55 +18,55 @@ const SwapTrendingTokensViewTestIds = { } as const; class SwapTrendingTokensView { - public get section(): DetoxElement { + public get section(): EncapsulatedElementType { return Matchers.getElementByID(BridgeTrendingTokensSectionTestIds.SECTION); } - public get priceFilter(): DetoxElement { + public get priceFilter(): EncapsulatedElementType { return Matchers.getElementByID( BridgeTrendingTokensSectionTestIds.PRICE_FILTER, ); } - public get networkFilter(): DetoxElement { + public get networkFilter(): EncapsulatedElementType { return Matchers.getElementByID( BridgeTrendingTokensSectionTestIds.NETWORK_FILTER, ); } - public get timeFilter(): DetoxElement { + public get timeFilter(): EncapsulatedElementType { return Matchers.getElementByID( BridgeTrendingTokensSectionTestIds.TIME_FILTER, ); } - public get priceBottomSheet(): DetoxElement { + public get priceBottomSheet(): EncapsulatedElementType { return Matchers.getElementByID( SwapTrendingTokensViewTestIds.PRICE_BOTTOM_SHEET, ); } - public get networkBottomSheet(): DetoxElement { + public get networkBottomSheet(): EncapsulatedElementType { return Matchers.getElementByID( SwapTrendingTokensViewTestIds.NETWORK_BOTTOM_SHEET, ); } - public get timeBottomSheet(): DetoxElement { + public get timeBottomSheet(): EncapsulatedElementType { return Matchers.getElementByID( SwapTrendingTokensViewTestIds.TIME_BOTTOM_SHEET, ); } - public get innerList(): DetoxElement { + public get innerList(): EncapsulatedElementType { return Matchers.getElementByID(SwapTrendingTokensViewTestIds.INNER_LIST); } - public get closeButton(): DetoxElement { + public get closeButton(): EncapsulatedElementType { return Matchers.getElementByID(SwapTrendingTokensViewTestIds.CLOSE_BUTTON); } - public get timeSelectSixHours(): DetoxElement { + public get timeSelectSixHours(): EncapsulatedElementType { return Matchers.getElementByID( SwapTrendingTokensViewTestIds.TIME_SELECT_6H, ); @@ -106,7 +111,7 @@ class SwapTrendingTokensView { }); } - tokenRow(assetId: string): DetoxElement { + tokenRow(assetId: string): EncapsulatedElementType { return Matchers.getElementByID( `${SwapTrendingTokensViewTestIds.TOKEN_ROW_PREFIX}${assetId}`, ); diff --git a/tests/page-objects/wallet/AccountActionsBottomSheet.ts b/tests/page-objects/wallet/AccountActionsBottomSheet.ts index f1bb9a30ca3..b12a131ea66 100644 --- a/tests/page-objects/wallet/AccountActionsBottomSheet.ts +++ b/tests/page-objects/wallet/AccountActionsBottomSheet.ts @@ -5,38 +5,39 @@ import Utilities from '../../framework/Utilities'; import EditAccountNameView from './EditAccountNameView'; import MultichainAccountDetails from '../MultichainAccounts/AccountDetails'; import MultichainEditAccountName from '../MultichainAccounts/EditAccountName'; +import { EncapsulatedElementType } from '../../framework'; class AccountActionsBottomSheet { - get editAccount(): DetoxElement { + get editAccount(): EncapsulatedElementType { return Matchers.getElementByID( AccountActionsBottomSheetSelectorsIDs.EDIT_ACCOUNT, ); } - get showPrivateKey(): DetoxElement { + get showPrivateKey(): EncapsulatedElementType { return Matchers.getElementByID( AccountActionsBottomSheetSelectorsIDs.SHOW_PRIVATE_KEY, ); } - get switchToSmartAccount(): DetoxElement { + get switchToSmartAccount(): EncapsulatedElementType { return Matchers.getElementByID( AccountActionsBottomSheetSelectorsIDs.SWITCH_TO_SMART_ACCOUNT, ); } - get showSrp(): DetoxElement { + get showSrp(): EncapsulatedElementType { return Matchers.getElementByID( AccountActionsBottomSheetSelectorsIDs.SHOW_SECRET_RECOVERY_PHRASE, ); } - get multichainEditName(): DetoxElement { + get multichainEditName(): EncapsulatedElementType { return Matchers.getElementByID( AccountActionsBottomSheetSelectorsIDs.MULTICHAIN_EDIT_NAME, ); } - get multichainAccountDetails(): DetoxElement { + get multichainAccountDetails(): EncapsulatedElementType { return Matchers.getElementByID( AccountActionsBottomSheetSelectorsIDs.MULTICHAIN_ACCOUNT_DETAILS, ); diff --git a/tests/page-objects/wallet/AccountListBottomSheet.ts b/tests/page-objects/wallet/AccountListBottomSheet.ts index ab69121e8cf..142d73a2362 100644 --- a/tests/page-objects/wallet/AccountListBottomSheet.ts +++ b/tests/page-objects/wallet/AccountListBottomSheet.ts @@ -44,24 +44,24 @@ class AccountListBottomSheet { }); } - get accountTypeLabel(): DetoxElement { + get accountTypeLabel(): EncapsulatedElementType { return Matchers.getElementByID( AccountListBottomSheetSelectorsIDs.ACCOUNT_TYPE_LABEL, ); } - get accountTagLabel(): DetoxElement { + get accountTagLabel(): EncapsulatedElementType { return Matchers.getElementByID(CellComponentSelectorsIDs.TAG_LABEL); } - get title(): DetoxElement { + get title(): EncapsulatedElementType { return Matchers.getElementByText( AccountListBottomSheetSelectorsText.ACCOUNTS_LIST_TITLE, ); } /** Header back control (same testID as CommonView.backButton / AccountSelector HeaderCompactStandard). */ - get backButton(): DetoxElement { + get backButton(): EncapsulatedElementType { return Matchers.getElementByID(CommonSelectorsIDs.BACK_ARROW_BUTTON); } @@ -80,19 +80,19 @@ class AccountListBottomSheet { }); } - get addEthereumAccountButton(): DetoxElement { + get addEthereumAccountButton(): EncapsulatedElementType { return Matchers.getElementByText( AccountListBottomSheetSelectorsText.ADD_ETHEREUM_ACCOUNT, ); } - get removeAccountAlertText(): DetoxElement { + get removeAccountAlertText(): EncapsulatedElementType { return Matchers.getElementByText( AccountListBottomSheetSelectorsText.REMOVE_IMPORTED_ACCOUNT, ); } - get connectAccountsButton(): DetoxElement { + get connectAccountsButton(): EncapsulatedElementType { return Matchers.getElementByID( ConnectAccountBottomSheetSelectorsIDs.SELECT_MULTI_BUTTON, ); @@ -122,15 +122,17 @@ class AccountListBottomSheet { ); } - getAccountElementByAccountNameV2(accountName: string): DetoxElement { + getAccountElementByAccountNameV2( + accountName: string, + ): EncapsulatedElementType { return Matchers.getElementByIDAndLabel(AccountCellIds.ADDRESS, accountName); } - async getSelectElement(index: number): DetoxElement { + getSelectElement(index: number): EncapsulatedElementType { return Matchers.getElementByID(CellComponentSelectorsIDs.SELECT, index); } - async getMultiselectElement(index: number): Promise { + getMultiselectElement(index: number): EncapsulatedElementType { return Matchers.getElementByID( CellComponentSelectorsIDs.MULTISELECT, index, @@ -145,7 +147,7 @@ class AccountListBottomSheet { * @param {number} index - The index of the element to retrieve. * @returns {Detox.IndexableNativeElement} The matcher for the element's title/name. */ - getSelectWithMenuElementName(index: number): DetoxElement { + getSelectWithMenuElementName(index: number): EncapsulatedElementType { return Matchers.getElementByID(CellComponentSelectorsIDs.BASE_TITLE, index); } @@ -317,7 +319,7 @@ class AccountListBottomSheet { } // V2 Multichain Accounts Methods - get ellipsisMenuButton(): DetoxElement { + get ellipsisMenuButton(): EncapsulatedElementType { return Matchers.getElementByID(AccountCellIds.MENU); } diff --git a/tests/page-objects/wallet/AddAccountBottomSheet.ts b/tests/page-objects/wallet/AddAccountBottomSheet.ts index a2d00b69768..b19e1cd8188 100644 --- a/tests/page-objects/wallet/AddAccountBottomSheet.ts +++ b/tests/page-objects/wallet/AddAccountBottomSheet.ts @@ -13,7 +13,7 @@ const AddAccountBottomSheetSelectorsIDs = { }; class AddAccountBottomSheet { - get importAccountButton(): DetoxElement { + get importAccountButton(): EncapsulatedElementType { return Matchers.getElementByID( AddAccountBottomSheetSelectorsIDs.IMPORT_ACCOUNT_BUTTON, ); diff --git a/tests/page-objects/wallet/DefiPositionView.ts b/tests/page-objects/wallet/DefiPositionView.ts index cb182bc788c..6676dae104e 100644 --- a/tests/page-objects/wallet/DefiPositionView.ts +++ b/tests/page-objects/wallet/DefiPositionView.ts @@ -2,6 +2,7 @@ import { WalletViewSelectorsIDs } from '../../../app/components/Views/Wallet/Wal import { CommonSelectorsIDs } from '../../../app/util/Common.testIds'; import Assertions from '../../framework/Assertions'; import Matchers from '../../framework/Matchers'; +import { EncapsulatedElementType } from '../../framework'; /** * Page Object for the DeFi position details screen (e.g. "Aave V3"). @@ -9,14 +10,14 @@ import Matchers from '../../framework/Matchers'; */ class DefiPositionView { /** Main container of the position details screen */ - get container(): DetoxElement { + get container(): EncapsulatedElementType { return Matchers.getElementByID( WalletViewSelectorsIDs.DEFI_POSITIONS_DETAILS_CONTAINER, ); } /** Back arrow button in the navbar (navigates to DeFi list) */ - get backButton(): DetoxElement { + get backButton(): EncapsulatedElementType { return Matchers.getElementByID(CommonSelectorsIDs.BACK_ARROW_BUTTON); } diff --git a/tests/page-objects/wallet/DefiView.ts b/tests/page-objects/wallet/DefiView.ts index 3e66ff64a79..048252a0df6 100644 --- a/tests/page-objects/wallet/DefiView.ts +++ b/tests/page-objects/wallet/DefiView.ts @@ -1,7 +1,7 @@ import { WalletViewSelectorsIDs } from '../../../app/components/Views/Wallet/WalletView.testIds'; import Gestures from '../../framework/Gestures'; import Matchers from '../../framework/Matchers'; -import { Assertions } from '../../framework'; +import { Assertions, EncapsulatedElementType } from '../../framework'; /** * Page Object for the DeFi full view screen. @@ -10,14 +10,14 @@ import { Assertions } from '../../framework'; */ class DefiView { /** Main container wrapping the DeFi positions list and control bar */ - get container(): DetoxElement { + get container(): EncapsulatedElementType { return Matchers.getElementByID( WalletViewSelectorsIDs.DEFI_POSITIONS_CONTAINER, ); } /** Network filter button ("Popular networks" with chevron) */ - get networkFilter(): DetoxElement { + get networkFilter(): EncapsulatedElementType { return Matchers.getElementByID( WalletViewSelectorsIDs.DEFI_POSITIONS_NETWORK_FILTER, ); diff --git a/tests/page-objects/wallet/EditAccountNameView.ts b/tests/page-objects/wallet/EditAccountNameView.ts index 722090caf52..230189e0b3a 100644 --- a/tests/page-objects/wallet/EditAccountNameView.ts +++ b/tests/page-objects/wallet/EditAccountNameView.ts @@ -1,14 +1,15 @@ import Matchers from '../../framework/Matchers'; import Gestures from '../../framework/Gestures'; import { EditAccountNameSelectorIDs } from '../../../app/components/Views/EditAccountName/EditAccountName.testIds'; +import { EncapsulatedElementType } from '../../framework'; class EditAccountNameView { - get saveButton(): DetoxElement { + get saveButton(): EncapsulatedElementType { return Matchers.getElementByID( EditAccountNameSelectorIDs.EDIT_ACCOUNT_NAME_SAVE, ); } - get accountNameInput(): DetoxElement { + get accountNameInput(): EncapsulatedElementType { return Matchers.getElementByID( EditAccountNameSelectorIDs.ACCOUNT_NAME_INPUT, ); diff --git a/tests/page-objects/wallet/HomeSections.ts b/tests/page-objects/wallet/HomeSections.ts index 3c086d58283..7285478bc2a 100644 --- a/tests/page-objects/wallet/HomeSections.ts +++ b/tests/page-objects/wallet/HomeSections.ts @@ -1,4 +1,9 @@ -import { Assertions, Gestures, Matchers } from '../../framework'; +import { + Assertions, + Gestures, + Matchers, + EncapsulatedElementType, +} from '../../framework'; import { WalletAssetSelectorsIDs, WalletAssetSelectorsRegex, @@ -13,18 +18,18 @@ class TokensFullView { /** * Back button in the tokens full view header */ - get backButton(): DetoxElement { + get backButton(): EncapsulatedElementType { return Matchers.getElementByID(WalletViewSelectorsIDs.BACK_BUTTON); } /** * Network filter button in the tokens full view control bar */ - get networkFilterButton(): DetoxElement { + get networkFilterButton(): EncapsulatedElementType { return Matchers.getElementByID(WalletViewSelectorsIDs.TOKEN_NETWORK_FILTER); } - get stakedEthereumAssetRow(): DetoxElement { + get stakedEthereumAssetRow(): EncapsulatedElementType { return Promise.resolve( element( by diff --git a/tests/page-objects/wallet/ImportNFTFlow/ImportNFTView.ts b/tests/page-objects/wallet/ImportNFTFlow/ImportNFTView.ts index 03ed9ccd957..b8b2a7abc47 100644 --- a/tests/page-objects/wallet/ImportNFTFlow/ImportNFTView.ts +++ b/tests/page-objects/wallet/ImportNFTFlow/ImportNFTView.ts @@ -1,25 +1,26 @@ import Matchers from '../../../framework/Matchers'; import Gestures from '../../../framework/Gestures'; import { NFTImportScreenSelectorsIDs } from '../../../../app/components/Views/AddAsset/ImportAssetView.testIds'; +import { EncapsulatedElementType } from '../../../framework'; class ImportNFTView { - get container(): DetoxElement { + get container(): EncapsulatedElementType { return Matchers.getElementByID(NFTImportScreenSelectorsIDs.CONTAINER); } - get addressInput(): DetoxElement { + get addressInput(): EncapsulatedElementType { return Matchers.getElementByID( NFTImportScreenSelectorsIDs.ADDRESS_INPUT_BOX, ); } - get addressWarningMessage(): DetoxElement { + get addressWarningMessage(): EncapsulatedElementType { return Matchers.getElementByID( NFTImportScreenSelectorsIDs.ADDRESS_WARNING_MESSAGE, ); } - get identifierInput(): DetoxElement { + get identifierInput(): EncapsulatedElementType { return Matchers.getElementByID( NFTImportScreenSelectorsIDs.IDENTIFIER_INPUT_BOX, ); diff --git a/tests/page-objects/wallet/ImportTokenFlow/ConfirmAddAsset.ts b/tests/page-objects/wallet/ImportTokenFlow/ConfirmAddAsset.ts index 72d77db765b..fd419aa8aa5 100644 --- a/tests/page-objects/wallet/ImportTokenFlow/ConfirmAddAsset.ts +++ b/tests/page-objects/wallet/ImportTokenFlow/ConfirmAddAsset.ts @@ -4,33 +4,34 @@ import { } from '../../../../app/components/Views/AddAsset/ImportAssetView.testIds'; import Matchers from '../../../framework/Matchers'; import Gestures from '../../../framework/Gestures'; +import { EncapsulatedElementType } from '../../../framework'; class ConfirmAddAssetView { - get container(): DetoxElement { + get container(): EncapsulatedElementType { return Matchers.getElementByID( ImportTokenViewSelectorsIDs.ADD_CONFIRM_CUSTOM_ASSET, ); } - get cancelButton(): DetoxElement { + get cancelButton(): EncapsulatedElementType { return Matchers.getElementByText( ImportTokenViewSelectorsText.CANCEL_IMPORT_TOKEN, ); } - get confirmButton(): DetoxElement { + get confirmButton(): EncapsulatedElementType { return Matchers.getElementByID( ImportTokenViewSelectorsIDs.BOTTOMSHEETFOOTER_BUTTON_SUBSEQUENT, ); } - get cancelModal(): DetoxElement { + get cancelModal(): EncapsulatedElementType { return Matchers.getElementByID( ImportTokenViewSelectorsIDs.ADD_CANCEL_ADD_CUSTOM_ASSET_MODAL, ); } - get confirmButtonModal(): DetoxElement { + get confirmButtonModal(): EncapsulatedElementType { return Matchers.getElementByText( ImportTokenViewSelectorsText.CONFIRM_CANCEL_IMPORT_TOKEN, ); diff --git a/tests/page-objects/wallet/ImportTokenFlow/ImportTokensView.ts b/tests/page-objects/wallet/ImportTokenFlow/ImportTokensView.ts index 09c88402653..09a0c7cfecb 100644 --- a/tests/page-objects/wallet/ImportTokenFlow/ImportTokensView.ts +++ b/tests/page-objects/wallet/ImportTokenFlow/ImportTokensView.ts @@ -5,52 +5,52 @@ import { ImportTokenViewSelectorsText, } from '../../../../app/components/Views/AddAsset/ImportAssetView.testIds'; import { CellComponentSelectorsIDs } from '../../../../app/component-library/components/Cells/Cell/CellComponent.testIds'; -import { logger } from '../../../framework'; +import { logger, EncapsulatedElementType } from '../../../framework'; class ImportTokensView { - get searchTokenResult(): DetoxElement { + get searchTokenResult(): EncapsulatedElementType { return Matchers.getElementByID( ImportTokenViewSelectorsIDs.SEARCH_TOKEN_RESULT, ); } - get nextButton(): DetoxElement { + get nextButton(): EncapsulatedElementType { return Matchers.getElementByID(ImportTokenViewSelectorsIDs.NEXT_BUTTON); } - get networkInput(): DetoxElement { + get networkInput(): EncapsulatedElementType { return Matchers.getElementByID( ImportTokenViewSelectorsIDs.SELECT_NETWORK_BUTTON, ); } - get symbolInput(): DetoxElement { + get symbolInput(): EncapsulatedElementType { return Matchers.getElementByID(ImportTokenViewSelectorsIDs.SYMBOL_INPUT); } - get tokenSymbolText(): DetoxElement { + get tokenSymbolText(): EncapsulatedElementType { return Matchers.getElementByText(ImportTokenViewSelectorsText.TOKEN_SYMBOL); } - get addressInput(): DetoxElement { + get addressInput(): EncapsulatedElementType { return Matchers.getElementByID(ImportTokenViewSelectorsIDs.ADDRESS_INPUT); } - get decimalInput(): DetoxElement { + get decimalInput(): EncapsulatedElementType { return Matchers.getElementByID(ImportTokenViewSelectorsIDs.DECIMAL_INPUT); } - get customTokenTab(): DetoxElement { + get customTokenTab(): EncapsulatedElementType { return Matchers.getElementByText( ImportTokenViewSelectorsText.CUSTOM_TOKEN_TAB, ); } - get searchTokenBar(): DetoxElement { + get searchTokenBar(): EncapsulatedElementType { return Matchers.getElementByID(ImportTokenViewSelectorsIDs.SEARCH_BAR); } - get networkList(): DetoxElement { + get networkList(): EncapsulatedElementType { return Matchers.getElementByID(CellComponentSelectorsIDs.SELECT, 0); } diff --git a/tests/page-objects/wallet/LoginView.ts b/tests/page-objects/wallet/LoginView.ts index 83046d0bbef..7086ca7b5ea 100644 --- a/tests/page-objects/wallet/LoginView.ts +++ b/tests/page-objects/wallet/LoginView.ts @@ -44,11 +44,11 @@ class LoginView { }); } - get forgotPasswordButton(): DetoxElement { + get forgotPasswordButton(): EncapsulatedElementType { return Matchers.getElementByID(LoginViewSelectors.RESET_WALLET); } - get rememberMeSwitch(): DetoxElement { + get rememberMeSwitch(): EncapsulatedElementType { return Matchers.getElementByID(LoginViewSelectors.REMEMBER_ME_SWITCH); } diff --git a/tests/page-objects/wallet/MultiSrp/Common/SRPListComponent.ts b/tests/page-objects/wallet/MultiSrp/Common/SRPListComponent.ts index 57351faac4b..fd7e5cf0a95 100644 --- a/tests/page-objects/wallet/MultiSrp/Common/SRPListComponent.ts +++ b/tests/page-objects/wallet/MultiSrp/Common/SRPListComponent.ts @@ -1,8 +1,9 @@ import { SRPListSelectorsIDs } from '../../../../../app/components/UI/SRPList/SRPList.testIds'; import Matchers from '../../../../framework/Matchers'; +import { EncapsulatedElementType } from '../../../../framework'; class SRPListComponent { - get srpList(): DetoxElement { + get srpList(): EncapsulatedElementType { return Matchers.getElementByID(SRPListSelectorsIDs.SRP_LIST); } } diff --git a/tests/page-objects/wallet/NetworkManager.ts b/tests/page-objects/wallet/NetworkManager.ts index f62e9c5bb2f..e3bd0c9cad3 100644 --- a/tests/page-objects/wallet/NetworkManager.ts +++ b/tests/page-objects/wallet/NetworkManager.ts @@ -12,19 +12,20 @@ import { WalletViewSelectorsIDs, WalletViewSelectorsText, } from '../../../app/components/Views/Wallet/WalletView.testIds'; +import { EncapsulatedElementType } from '../../framework'; class NetworkManager { /** * Button to open the network manager */ - get openNetworkManagerButton(): DetoxElement { + get openNetworkManagerButton(): EncapsulatedElementType { return Matchers.getElementByID(WalletViewSelectorsIDs.TOKEN_NETWORK_FILTER); } /** * Select the bottom sheet of the network manager */ - get networkManagerBottomSheet(): DetoxElement { + get networkManagerBottomSheet(): EncapsulatedElementType { return Matchers.getElementByID( NetworkManagerSelectorIDs.NETWORK_MANAGER_BOTTOM_SHEET, ); @@ -33,7 +34,7 @@ class NetworkManager { /** * Select the tab of the popular networks */ - get popularNetworksTab(): DetoxElement { + get popularNetworksTab(): EncapsulatedElementType { return Matchers.getElementByText( NetworkManagerSelectorText.POPULAR_NETWORKS_TAB, ); @@ -42,7 +43,7 @@ class NetworkManager { /** * Select the tab of the custom networks */ - get customNetworksTab(): DetoxElement { + get customNetworksTab(): EncapsulatedElementType { return Matchers.getElementByText( NetworkManagerSelectorText.CUSTOM_NETWORKS_TAB, ); @@ -51,7 +52,7 @@ class NetworkManager { /** * Select the container of the popular networks tab */ - get popularNetworksContainer(): DetoxElement { + get popularNetworksContainer(): EncapsulatedElementType { return Matchers.getElementByID( NetworkManagerSelectorIDs.POPULAR_NETWORKS_CONTAINER, ); @@ -60,7 +61,7 @@ class NetworkManager { /** * Select the container of the custom networks tab */ - get customNetworksContainer(): DetoxElement { + get customNetworksContainer(): EncapsulatedElementType { return Matchers.getElementByID( NetworkManagerSelectorIDs.CUSTOM_NETWORKS_CONTAINER, ); @@ -69,7 +70,7 @@ class NetworkManager { /** * Select the button to select all popular networks */ - get selectAllPopularNetworksSelected(): DetoxElement { + get selectAllPopularNetworksSelected(): EncapsulatedElementType { return Matchers.getElementByID( NetworkManagerSelectorIDs.SELECT_ALL_POPULAR_NETWORKS_SELECTED, ); @@ -78,7 +79,7 @@ class NetworkManager { /** * Select the button to select all popular networks */ - get selectAllPopularNetworksNotSelected(): DetoxElement { + get selectAllPopularNetworksNotSelected(): EncapsulatedElementType { return Matchers.getElementByID( NetworkManagerSelectorIDs.SELECT_ALL_POPULAR_NETWORKS_NOT_SELECTED, ); @@ -87,7 +88,7 @@ class NetworkManager { /** * Select the network by name wether it is selected or not */ - getNetworkByCaipChainId(caipChainId: CaipChainId): DetoxElement { + getNetworkByCaipChainId(caipChainId: CaipChainId): EncapsulatedElementType { return Matchers.getElementByID( new RegExp(`^network-list-item-${caipChainId}-(selected|not-selected)$`), ); @@ -96,7 +97,9 @@ class NetworkManager { /** * Select the network by name if it is selected */ - getSelectedNetworkByCaipChainId(caipChainId: CaipChainId): DetoxElement { + getSelectedNetworkByCaipChainId( + caipChainId: CaipChainId, + ): EncapsulatedElementType { return Matchers.getElementByID( NetworkManagerSelectorIDs.NETWORK_LIST_ITEM(caipChainId, true), ); @@ -105,7 +108,9 @@ class NetworkManager { /** * Select the network by name if it is not selected */ - getNotSelectedNetworkByCaipChainId(caipChainId: CaipChainId): DetoxElement { + getNotSelectedNetworkByCaipChainId( + caipChainId: CaipChainId, + ): EncapsulatedElementType { return Matchers.getElementByID( NetworkManagerSelectorIDs.NETWORK_LIST_ITEM(caipChainId, false), ); @@ -114,7 +119,7 @@ class NetworkManager { /** * Select the network by name in the base control bar */ - getBaseControlBarText(caipChainId: CaipChainId): DetoxElement { + getBaseControlBarText(caipChainId: CaipChainId): EncapsulatedElementType { return Matchers.getElementByID( `${WalletViewSelectorsIDs.TOKEN_NETWORK_FILTER}-${caipChainId}`, ); @@ -124,7 +129,7 @@ class NetworkManager { * Get token element by symbol * Note: Gets the first instance in case of duplicates during render cycles */ - getTokenBySymbol(symbol: string): DetoxElement { + getTokenBySymbol(symbol: string): EncapsulatedElementType { return Matchers.getElementByID(`asset-${symbol}`, 0); } @@ -343,7 +348,7 @@ class NetworkManager { /** * Get network by display name (for custom networks) */ - getNetworkByName(networkName: string): DetoxElement { + getNetworkByName(networkName: string): EncapsulatedElementType { return Matchers.getElementByText(networkName); } diff --git a/tests/page-objects/wallet/NftDetectionModal.ts b/tests/page-objects/wallet/NftDetectionModal.ts index a3532908960..65accfee3fa 100644 --- a/tests/page-objects/wallet/NftDetectionModal.ts +++ b/tests/page-objects/wallet/NftDetectionModal.ts @@ -1,17 +1,18 @@ import { NftDetectionModalSelectorsIDs } from '../../../app/components/Views/NFTAutoDetectionModal/NftDetectionModal.testIds'; import Matchers from '../../framework/Matchers'; import Gestures from '../../framework/Gestures'; +import { EncapsulatedElementType } from '../../framework'; class NftDetectionModal { - get container(): DetoxElement { + get container(): EncapsulatedElementType { return Matchers.getElementByID(NftDetectionModalSelectorsIDs.CONTAINER); } - get cancelButton(): DetoxElement { + get cancelButton(): EncapsulatedElementType { return Matchers.getElementByID(NftDetectionModalSelectorsIDs.CANCEL_BUTTON); } - get allowButton(): DetoxElement { + get allowButton(): EncapsulatedElementType { return Matchers.getElementByID(NftDetectionModalSelectorsIDs.ALLOW_BUTTON); } diff --git a/tests/page-objects/wallet/SelectNetworkBottomSheet.ts b/tests/page-objects/wallet/SelectNetworkBottomSheet.ts index 0205a813131..33e1b8210a0 100644 --- a/tests/page-objects/wallet/SelectNetworkBottomSheet.ts +++ b/tests/page-objects/wallet/SelectNetworkBottomSheet.ts @@ -1,9 +1,10 @@ import { PermissionSummaryBottomSheetSelectorsText } from '../../../app/components/Views/MultichainAccounts/shared/PermissionSummaryBottomSheet.testIds'; import Gestures from '../../framework/Gestures'; import Matchers from '../../framework/Matchers'; +import { EncapsulatedElementType } from '../../framework'; class SelectNetworksBottomSheet { - get connectedAccountsText(): DetoxElement { + get connectedAccountsText(): EncapsulatedElementType { return Matchers.getElementByText( PermissionSummaryBottomSheetSelectorsText.CONNECTED_ACCOUNTS_TEXT, ); diff --git a/tests/page-objects/wallet/SendActionBottomSheet.ts b/tests/page-objects/wallet/SendActionBottomSheet.ts index 75176f11209..75d1d88b9b8 100644 --- a/tests/page-objects/wallet/SendActionBottomSheet.ts +++ b/tests/page-objects/wallet/SendActionBottomSheet.ts @@ -1,39 +1,40 @@ import { SendActionViewSelectorsIDs } from '../../selectors/SendFlow/SendActionView.selectors'; import Gestures from '../../framework/Gestures'; import Matchers from '../../framework/Matchers'; +import { EncapsulatedElementType } from '../../framework'; class SendActionBottomSheet { - get solanaAddressInputField(): DetoxElement { + get solanaAddressInputField(): EncapsulatedElementType { return Matchers.getElementByID( SendActionViewSelectorsIDs.SOLANA_INPUT_ADDRESS_FIELD, ); } - get solanaAmountInputField(): DetoxElement { + get solanaAmountInputField(): EncapsulatedElementType { return Matchers.getElementByID( SendActionViewSelectorsIDs.SOLANA_INPUT_AMOUNT_FIELD, ); } - get invalidAddressError(): DetoxElement { + get invalidAddressError(): EncapsulatedElementType { return Matchers.getElementByID( SendActionViewSelectorsIDs.INVALID_ADDRESS_ERROR, ); } - get continueButton(): DetoxElement { + get continueButton(): EncapsulatedElementType { return Matchers.getElementByID(SendActionViewSelectorsIDs.CONTINUE_BUTTON); } - get cancelButton(): DetoxElement { + get cancelButton(): EncapsulatedElementType { return Matchers.getElementByID(SendActionViewSelectorsIDs.CANCEL_BUTTON); } - get closeModalButton(): DetoxElement { + get closeModalButton(): EncapsulatedElementType { return Matchers.getElementByID(SendActionViewSelectorsIDs.CLOSE_BUTTON); } - get sendSOLTransactionButton(): DetoxElement { + get sendSOLTransactionButton(): EncapsulatedElementType { return Matchers.getElementByID( SendActionViewSelectorsIDs.SEND_TRANSACTION_BUTTON, ); diff --git a/tests/page-objects/wallet/ToastModal.ts b/tests/page-objects/wallet/ToastModal.ts index 494121d026b..186a24087ae 100644 --- a/tests/page-objects/wallet/ToastModal.ts +++ b/tests/page-objects/wallet/ToastModal.ts @@ -21,7 +21,7 @@ const DEFAULT_TOAST_APPEAR_TIMEOUT_MS = 5_000; const TOAST_POLL_INTERVAL_MS = 250; class ToastModal { - get container(): DetoxElement { + get container(): EncapsulatedElementType { return Matchers.getElementByID(ToastSelectorsIDs.CONTAINER); } @@ -35,11 +35,11 @@ class ToastModal { }); } - get notificationTitle(): DetoxElement { + get notificationTitle(): EncapsulatedElementType { return Matchers.getElementByID(ToastSelectorsIDs.NOTIFICATION_TITLE); } - get toastCloseButton(): DetoxElement { + get toastCloseButton(): EncapsulatedElementType { return Matchers.getElementByText(ToastSelectorsText.CLOSE_BUTTON); } diff --git a/tests/page-objects/wallet/TokenOverview.ts b/tests/page-objects/wallet/TokenOverview.ts index a42ae755d72..7bf2367b0c6 100644 --- a/tests/page-objects/wallet/TokenOverview.ts +++ b/tests/page-objects/wallet/TokenOverview.ts @@ -33,7 +33,7 @@ class TokenOverview { }); } - get tokenPrice(): DetoxElement { + get tokenPrice(): EncapsulatedElementType { return Matchers.getElementByID(TokenOverviewSelectorsIDs.TOKEN_PRICE); } @@ -91,73 +91,73 @@ class TokenOverview { }); } - get unstakeButton(): DetoxElement { + get unstakeButton(): EncapsulatedElementType { return Matchers.getElementByID(WalletViewSelectorsIDs.UNSTAKE_BUTTON); } - get stakeMoreButton(): DetoxElement { + get stakeMoreButton(): EncapsulatedElementType { return Matchers.getElementByID(WalletViewSelectorsIDs.STAKE_MORE_BUTTON); } - get stakedBalance(): DetoxElement { + get stakedBalance(): EncapsulatedElementType { return Matchers.getElementByID(TokenOverviewSelectorsText.STAKED_BALANCE); } - get actionSheetSendButton(): DetoxElement { + get actionSheetSendButton(): EncapsulatedElementType { return Matchers.getElementByID( WalletActionsBottomSheetSelectorsIDs.SEND_BUTTON, ); } - get swapButton(): DetoxElement { + get swapButton(): EncapsulatedElementType { return Matchers.getElementByID(TokenOverviewSelectorsIDs.SWAP_BUTTON); } - get bridgeButton(): DetoxElement { + get bridgeButton(): EncapsulatedElementType { return Matchers.getElementByID(TokenOverviewSelectorsIDs.BRIDGE_BUTTON); } - get claimButton(): DetoxElement { + get claimButton(): EncapsulatedElementType { return Matchers.getElementByID(TokenOverviewSelectorsIDs.CLAIM_BUTTON); } - get receiveButton(): DetoxElement { + get receiveButton(): EncapsulatedElementType { return Matchers.getElementByID(TokenOverviewSelectorsIDs.RECEIVE_BUTTON); } - get noChartData(): DetoxElement { + get noChartData(): EncapsulatedElementType { return Matchers.getElementByText(TokenOverviewSelectorsText.NO_CHART_DATA); } - get closeButton(): DetoxElement { + get closeButton(): EncapsulatedElementType { return Matchers.getElementByID(CommonSelectorsIDs.BACK_ARROW_BUTTON); } - get unstakingBanner(): DetoxElement { + get unstakingBanner(): EncapsulatedElementType { return Matchers.getElementByID(TokenOverviewSelectorsIDs.UNSTAKING_BANNER); } - get chartPeriod1d(): DetoxElement { + get chartPeriod1d(): EncapsulatedElementType { return Matchers.getElementByText(TokenOverviewSelectorsText['1d']); } - get chartPeriod1w(): DetoxElement { + get chartPeriod1w(): EncapsulatedElementType { return Matchers.getElementByText(TokenOverviewSelectorsText['1w']); } - get chartPeriod1m(): DetoxElement { + get chartPeriod1m(): EncapsulatedElementType { return Matchers.getElementByText(TokenOverviewSelectorsText['1m']); } - get chartPeriod3m(): DetoxElement { + get chartPeriod3m(): EncapsulatedElementType { return Matchers.getElementByText(TokenOverviewSelectorsText['3m']); } - get chartPeriod1y(): DetoxElement { + get chartPeriod1y(): EncapsulatedElementType { return Matchers.getElementByText(TokenOverviewSelectorsText['1y']); } - get chartPeriod3y(): DetoxElement { + get chartPeriod3y(): EncapsulatedElementType { return Matchers.getElementByText(TokenOverviewSelectorsText['3y']); } diff --git a/tests/page-objects/wallet/TokenSortBottomSheet.ts b/tests/page-objects/wallet/TokenSortBottomSheet.ts index d4a4e70d30f..97fd4eb7326 100644 --- a/tests/page-objects/wallet/TokenSortBottomSheet.ts +++ b/tests/page-objects/wallet/TokenSortBottomSheet.ts @@ -1,13 +1,14 @@ import { WalletViewSelectorsIDs } from '../../../app/components/Views/Wallet/WalletView.testIds'; import Gestures from '../../framework/Gestures'; import Matchers from '../../framework/Matchers'; +import { EncapsulatedElementType } from '../../framework'; class SortModal { - get sortAlphabetically(): DetoxElement { + get sortAlphabetically(): EncapsulatedElementType { return Matchers.getElementByID(WalletViewSelectorsIDs.SORT_ALPHABETICAL); } - get sortFiatAmount(): DetoxElement { + get sortFiatAmount(): EncapsulatedElementType { return Matchers.getElementByID( WalletViewSelectorsIDs.SORT_DECLINING_BALANCE, ); diff --git a/tests/page-objects/wallet/TokensView.ts b/tests/page-objects/wallet/TokensView.ts index 6a39d1e92ba..be3197dad78 100644 --- a/tests/page-objects/wallet/TokensView.ts +++ b/tests/page-objects/wallet/TokensView.ts @@ -5,13 +5,14 @@ import Gestures from '../../framework/Gestures'; import Matchers from '../../framework/Matchers'; import Utilities from '../../framework/Utilities'; import NetworkManager from './NetworkManager'; +import { EncapsulatedElementType } from '../../framework'; class TokensView { - get networkFilter(): DetoxElement { + get networkFilter(): EncapsulatedElementType { return Matchers.getElementByID(WalletViewSelectorsIDs.TOKEN_NETWORK_FILTER); } - earnCtaForToken(tokenSymbol: string): DetoxElement { + earnCtaForToken(tokenSymbol: string): EncapsulatedElementType { return Matchers.getElementIDWithAncestor( SECONDARY_BALANCE_BUTTON_TEST_ID, getAssetTestId(tokenSymbol), diff --git a/tests/page-objects/wallet/WalletActionsBottomSheet.ts b/tests/page-objects/wallet/WalletActionsBottomSheet.ts index 333a3ed67e8..d7bad8415b3 100644 --- a/tests/page-objects/wallet/WalletActionsBottomSheet.ts +++ b/tests/page-objects/wallet/WalletActionsBottomSheet.ts @@ -11,37 +11,37 @@ import PlaywrightMatchers from '../../framework/PlaywrightMatchers'; import { encapsulatedAction } from '../../framework/encapsulatedAction'; class WalletActionsBottomSheet { - get sendButton(): DetoxElement { + get sendButton(): EncapsulatedElementType { return Matchers.getElementByID( WalletActionsBottomSheetSelectorsIDs.SEND_BUTTON, ); } - get receiveButton(): DetoxElement { + get receiveButton(): EncapsulatedElementType { return Matchers.getElementByID( WalletActionsBottomSheetSelectorsIDs.RECEIVE_BUTTON, ); } - get swapButton(): DetoxElement { + get swapButton(): EncapsulatedElementType { return Matchers.getElementByID( WalletActionsBottomSheetSelectorsIDs.SWAP_BUTTON, ); } - get bridgeButton(): DetoxElement { + get bridgeButton(): EncapsulatedElementType { return Matchers.getElementByID( WalletActionsBottomSheetSelectorsIDs.BRIDGE_BUTTON, ); } - get buyButton(): DetoxElement { + get buyButton(): EncapsulatedElementType { return Matchers.getElementByID( WalletActionsBottomSheetSelectorsIDs.BUY_BUTTON, ); } - get sellButton(): DetoxElement { + get sellButton(): EncapsulatedElementType { return Matchers.getElementByID( WalletActionsBottomSheetSelectorsIDs.SELL_BUTTON, ); diff --git a/tests/page-objects/wallet/WalletView.ts b/tests/page-objects/wallet/WalletView.ts index f83fd027cd0..cbec5d41f25 100644 --- a/tests/page-objects/wallet/WalletView.ts +++ b/tests/page-objects/wallet/WalletView.ts @@ -58,7 +58,7 @@ class WalletView { } /** Wallet ScrollView as element (for gestures like swipe). */ - get walletScrollView(): DetoxElement { + get walletScrollView(): EncapsulatedElementType { return Matchers.getElementByID(WalletViewSelectorsIDs.WALLET_SCROLL_VIEW); } @@ -70,7 +70,7 @@ class WalletView { * from the tab bar (e.g. direction 'up' = one more scroll down = section moves higher on screen). */ private async scrollAndTapSection( - target: DetoxElement, + target: DetoxElement | EncapsulatedElementType, description: string, direction: 'up' | 'down' = 'down', options: { @@ -96,7 +96,7 @@ class WalletView { }); } - get earnButton(): DetoxElement { + get earnButton(): EncapsulatedElementType { return Matchers.getElementByID(WalletViewSelectorsIDs.STAKE_BUTTON); } @@ -104,19 +104,19 @@ class WalletView { * The "Earn" CTA on the USDC token row's secondary balance area. * Index 2 = USDC (third token: ETH → mUSD → USDC) in the standard lending fixture. */ - get lendingEarnCta(): DetoxElement { + get lendingEarnCta(): EncapsulatedElementType { return Matchers.getElementByID(SECONDARY_BALANCE_BUTTON_TEST_ID, 2); } - get stakedEthereumLabel(): DetoxElement { + get stakedEthereumLabel(): EncapsulatedElementType { return Matchers.getElementByText(WalletViewSelectorsText.STAKED_ETHEREUM); } - get stakeMoreButton(): DetoxElement { + get stakeMoreButton(): EncapsulatedElementType { return Matchers.getElementByID(WalletViewSelectorsIDs.STAKE_MORE_BUTTON); } - get tokenDetectionLinkButton(): DetoxElement { + get tokenDetectionLinkButton(): EncapsulatedElementType { return Matchers.getElementByID( WalletViewSelectorsIDs.WALLET_TOKEN_DETECTION_LINK_BUTTON, ); @@ -138,11 +138,11 @@ class WalletView { }); } - get eyeSlashIcon(): DetoxElement { + get eyeSlashIcon(): EncapsulatedElementType { return Matchers.getElementByID(WalletViewSelectorsIDs.EYE_SLASH_ICON); } - get notificationBellIcon(): DetoxElement { + get notificationBellIcon(): EncapsulatedElementType { return Matchers.getElementByID( WalletViewSelectorsIDs.WALLET_NOTIFICATIONS_BUTTON, ); @@ -162,7 +162,7 @@ class WalletView { }); } - get navbarNetworkText(): DetoxElement { + get navbarNetworkText(): EncapsulatedElementType { return Matchers.getElementByID(WalletViewSelectorsIDs.NAVBAR_NETWORK_TEXT); } @@ -178,33 +178,33 @@ class WalletView { }); } - get navbarNetworkPicker(): DetoxElement { + get navbarNetworkPicker(): EncapsulatedElementType { return Matchers.getElementByID( WalletViewSelectorsIDs.NAVBAR_NETWORK_PICKER, ); } - get navbarCardButton(): DetoxElement { + get navbarCardButton(): EncapsulatedElementType { return Matchers.getElementByID(WalletViewSelectorsIDs.CARD_BUTTON); } - get nftTab(): DetoxElement { + get nftTab(): EncapsulatedElementType { return Matchers.getElementByText(WalletViewSelectorsText.NFTS_TAB); } - get nftTabContainer(): DetoxElement { + get nftTabContainer(): EncapsulatedElementType { return Matchers.getElementByID(WalletViewSelectorsIDs.NFT_TAB_CONTAINER); } - get importNFTButton(): DetoxElement { + get importNFTButton(): EncapsulatedElementType { return Matchers.getElementByID(WalletViewSelectorsIDs.IMPORT_NFT_BUTTON); } - get importTokensButton(): DetoxElement { + get importTokensButton(): EncapsulatedElementType { return Matchers.getElementByID(WalletViewSelectorsIDs.IMPORT_TOKEN_BUTTON); } - get networkName(): DetoxElement { + get networkName(): EncapsulatedElementType { return Matchers.getElementByID(WalletViewSelectorsIDs.NETWORK_NAME); } @@ -231,7 +231,7 @@ class WalletView { }); } - get accountName(): DetoxElement { + get accountName(): EncapsulatedElementType { return Matchers.getElementByID( WalletViewSelectorsIDs.ACCOUNT_NAME_LABEL_TEXT, ); @@ -262,57 +262,57 @@ class WalletView { }); } - get hideTokensLabel(): DetoxElement { + get hideTokensLabel(): EncapsulatedElementType { return Matchers.getElementByText(WalletViewSelectorsText.HIDE_TOKENS); } - get currentMainWalletAccountActions(): DetoxElement { + get currentMainWalletAccountActions(): EncapsulatedElementType { return Matchers.getElementByID( WalletViewSelectorsIDs.ACCOUNT_NAME_LABEL_TEXT, ); } - get tokenNetworkFilter(): DetoxElement { + get tokenNetworkFilter(): EncapsulatedElementType { return Matchers.getElementByID(WalletViewSelectorsIDs.TOKEN_NETWORK_FILTER); } - get sortButton(): DetoxElement { + get sortButton(): EncapsulatedElementType { return Matchers.getElementByID(WalletViewSelectorsIDs.SORT_BUTTON); } - get sortBy(): DetoxElement { + get sortBy(): EncapsulatedElementType { return Matchers.getElementByID(WalletViewSelectorsIDs.SORT_BY); } - get tokenNetworkFilterAll(): DetoxElement { + get tokenNetworkFilterAll(): EncapsulatedElementType { return Matchers.getElementByID( WalletViewSelectorsIDs.TOKEN_NETWORK_FILTER_ALL, ); } - get tokenNetworkFilterCurrent(): DetoxElement { + get tokenNetworkFilterCurrent(): EncapsulatedElementType { return Matchers.getElementByID( WalletViewSelectorsIDs.TOKEN_NETWORK_FILTER_CURRENT, ); } - get cancelButton(): DetoxElement { + get cancelButton(): EncapsulatedElementType { return Matchers.getElementByText('Cancel'); } - get carouselContainer(): DetoxElement { + get carouselContainer(): EncapsulatedElementType { return Matchers.getElementByID(WalletViewSelectorsIDs.CAROUSEL_CONTAINER); } - get carouselProgressDots(): DetoxElement { + get carouselProgressDots(): EncapsulatedElementType { return Matchers.getElementByID( WalletViewSelectorsIDs.CAROUSEL_PROGRESS_DOTS, ); } - get testCollectible(): DetoxElement { + get testCollectible(): EncapsulatedElementType { return Matchers.getElementByID(WalletViewSelectorsIDs.TEST_COLLECTIBLE, 1); } - get testCollectibleFallback(): DetoxElement { + get testCollectibleFallback(): EncapsulatedElementType { return Matchers.getElementByID( WalletViewSelectorsIDs.COLLECTIBLE_FALLBACK, 1, @@ -349,7 +349,7 @@ class WalletView { }); } - get walletBridgeButton(): DetoxElement { + get walletBridgeButton(): EncapsulatedElementType { return Matchers.getElementByID(WalletViewSelectorsIDs.WALLET_BRIDGE_BUTTON); } @@ -372,77 +372,81 @@ class WalletView { } // mUSD conversion (Earn) - asset list CTA, education screen, token list CTA, asset overview CTA - get musdConversionCta(): DetoxElement { + get musdConversionCta(): EncapsulatedElementType { return Matchers.getElementByID( EARN_TEST_IDS.MUSD.ASSET_LIST_CONVERSION_CTA, ); } - get cashGetMusdContainer(): DetoxElement { + get cashGetMusdContainer(): EncapsulatedElementType { return Matchers.getElementByID(CashGetMusdEmptyStateSelectors.CONTAINER); } - get getMusdButton(): DetoxElement { + get getMusdButton(): EncapsulatedElementType { return Matchers.getElementByText('Get mUSD'); } - get getStartedButton(): DetoxElement { + get getStartedButton(): EncapsulatedElementType { return Matchers.getElementByText('Get Started'); } /** Token list item CTA: "Get 3% mUSD bonus" on USDC row. Use testID + index (1 = USDC after ETH) to avoid regex/text flakiness. */ - get tokenListItemConvertToMusdCta(): DetoxElement { + get tokenListItemConvertToMusdCta(): EncapsulatedElementType { return Matchers.getElementByID(SECONDARY_BALANCE_BUTTON_TEST_ID, 1); } - get assetOverviewMusdCta(): DetoxElement { + get assetOverviewMusdCta(): EncapsulatedElementType { return Matchers.getElementByID( EARN_TEST_IDS.MUSD.ASSET_OVERVIEW_CONVERSION_CTA, ); } - get walletReceiveButton(): DetoxElement { + get walletReceiveButton(): EncapsulatedElementType { return Matchers.getElementByID( WalletViewSelectorsIDs.WALLET_RECEIVE_BUTTON, ); } // Balance Empty State - displayed when account group has zero balance across all networks - get balanceEmptyStateContainer(): DetoxElement { + get balanceEmptyStateContainer(): EncapsulatedElementType { return Matchers.getElementByID( WalletViewSelectorsIDs.BALANCE_EMPTY_STATE_CONTAINER, ); } - get balanceEmptyStateActionButton(): DetoxElement { + get balanceEmptyStateActionButton(): EncapsulatedElementType { return Matchers.getElementByID( WalletViewSelectorsIDs.BALANCE_EMPTY_STATE_ACTION_BUTTON, ); } - getPredictCurrentPositionCardByIndex(index: number = 0): DetoxElement { + getPredictCurrentPositionCardByIndex( + index: number = 0, + ): EncapsulatedElementType { return Matchers.getElementByID( PredictPositionSelectorsIDs.CURRENT_POSITION_CARD, index, ); } - getPredictResolvedPositionCardByIndex(index: number = 0): DetoxElement { + getPredictResolvedPositionCardByIndex( + index: number = 0, + ): EncapsulatedElementType { return Matchers.getElementByID( PredictPositionSelectorsIDs.RESOLVED_POSITION_CARD, index, ); } - getCarouselSlide(id: string): DetoxElement { + getCarouselSlide(id: string): EncapsulatedElementType { return Matchers.getElementByID(WalletViewSelectorsIDs.CAROUSEL_SLIDE(id)); } - getCarouselSlideTitle(id: string): DetoxElement { + getCarouselSlideTitle(id: string): EncapsulatedElementType { return Matchers.getElementByID( WalletViewSelectorsIDs.CAROUSEL_SLIDE_TITLE(id), ); } - getCarouselSlideCloseButton(id: string): DetoxElement { + getCarouselSlideCloseButton(id: string): EncapsulatedElementType { return Matchers.getElementByID( WalletViewSelectorsIDs.CAROUSEL_SLIDE_CLOSE_BUTTON(id), ); @@ -648,21 +652,21 @@ class WalletView { }); } - async tokenInWallet(tokenName: string): Promise { + async tokenInWallet(tokenName: string): Promise { return Matchers.getElementByText(tokenName); } - async getTokensInWallet(): Promise { + async getTokensInWallet(): Promise { return Matchers.getElementByID( WalletViewSelectorsIDs.TOKENS_CONTAINER_LIST, ); } - async nftIDInWallet(nftId: string): Promise { + async nftIDInWallet(nftId: string): Promise { return Matchers.getElementByID(nftId); } - async nftInWallet(nftName: string): Promise { + async nftInWallet(nftName: string): Promise { return Matchers.getElementByText(nftName); } @@ -756,65 +760,65 @@ class WalletView { }); } - get defiTab(): DetoxElement { + get defiTab(): EncapsulatedElementType { return Matchers.getElementByText(WalletViewSelectorsText.DEFI_TAB); } - get defiNetworkFilter(): DetoxElement { + get defiNetworkFilter(): EncapsulatedElementType { return Matchers.getElementByID( WalletViewSelectorsIDs.DEFI_POSITIONS_NETWORK_FILTER, ); } - get defiTabContainer(): DetoxElement { + get defiTabContainer(): EncapsulatedElementType { return Matchers.getElementByID( WalletViewSelectorsIDs.DEFI_POSITIONS_CONTAINER, ); } - get claimButton(): DetoxElement { + get claimButton(): EncapsulatedElementType { return Matchers.getElementByID( PredictPositionsHeaderSelectorsIDs.CLAIM_BUTTON, ); } - get predictClaimConfirmButton(): DetoxElement { + get predictClaimConfirmButton(): EncapsulatedElementType { return Matchers.getElementByID( PredictClaimConfirmationSelectorsIDs.CLAIM_CONFIRM_BUTTON, ); } - get defiPositionDetailsContainer(): DetoxElement { + get defiPositionDetailsContainer(): EncapsulatedElementType { return Matchers.getElementByID( WalletViewSelectorsIDs.DEFI_POSITIONS_DETAILS_CONTAINER, ); } - get predictionsTab(): DetoxElement { + get predictionsTab(): EncapsulatedElementType { return Matchers.getElementByLabel(WalletViewSelectorsText.PREDICTIONS_TAB); } - get availableBalanceLabel(): DetoxElement { + get availableBalanceLabel(): EncapsulatedElementType { return Matchers.getElementByText(WalletViewSelectorsText.AVAILABLE_BALANCE); } - get defiPositionsNew(): DetoxElement { + get defiPositionsNew(): EncapsulatedElementType { return Matchers.getElementByText(WalletViewSelectorsText.DEFI_SECTION); } /** Perpetuals section title button on the homepage. */ - get perpsSectionHeader(): DetoxElement { + get perpsSectionHeader(): EncapsulatedElementType { return Matchers.getElementByLabel( WalletViewSelectorsText.PERPETUALS_SECTION, ); } /** Predictions section title button on the homepage. */ - get predictionsSectionHeader(): DetoxElement { + get predictionsSectionHeader(): EncapsulatedElementType { return Matchers.getElementByID( WalletViewSelectorsIDs.HOMEPAGE_SECTION_TITLE('predictions'), ); } /** Tokens section header on the homepage. */ - get tokensSectionHeader(): DetoxElement { + get tokensSectionHeader(): EncapsulatedElementType { return Matchers.getElementByText(WalletViewSelectorsText.TOKENS_SECTION); } @@ -838,7 +842,7 @@ class WalletView { } /** NFTs section header on the homepage. */ - get nftsSectionHeader(): DetoxElement { + get nftsSectionHeader(): EncapsulatedElementType { return Matchers.getElementByText(WalletViewSelectorsText.NFTS_SECTION); } @@ -1251,7 +1255,7 @@ class WalletView { }); } - get perpsTab(): DetoxElement { + get perpsTab(): EncapsulatedElementType { return Matchers.getElementByText(WalletViewSelectorsText.PERPS_TAB); } diff --git a/tests/regression/wallet/carousel.spec.ts b/tests/regression/wallet/carousel.spec.ts index ab0ebc5ccf7..31ad73fe093 100644 --- a/tests/regression/wallet/carousel.spec.ts +++ b/tests/regression/wallet/carousel.spec.ts @@ -89,7 +89,8 @@ describe(RegressionWalletUX('Carousel Tests'), () => { await device.disableSynchronization(); // Find and dismiss any available slide - const firstSlide = await WalletView.carouselContainer; + const firstSlide = + (await WalletView.carouselContainer) as Detox.IndexableNativeElement; await firstSlide.tap(); await Assertions.expectElementToBeVisible( From b3e7eb887ee5ad923f076f3c74c2a84a5f33825d Mon Sep 17 00:00:00 2001 From: Xavier Brochard Date: Wed, 10 Jun 2026 17:54:48 +0200 Subject: [PATCH 15/15] fix(notifications): suppress duplicate toasts for quickbuy trades (#31462) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ## **Description** QuickBuy surfaces its own pending/complete/failed toasts. When a trade is submitted via an EIP-7702 smart account or a gas-included (`gasIncluded7702`) quote, `BridgeStatusController.submitTx` wraps the trade as a `TransactionType.batch` transaction. `batch` is in `REDESIGNED_TRANSACTION_TYPES`, so `NotificationManager` also fired the generic "Transaction submitted" and "Transaction #N complete" toasts on top of QuickBuy's own — the user saw **4 toasts instead of 2**. (This is why it doesn't reproduce on a plain EOA: those trades are typed `swap`/`bridge`, which are not redesigned types.) This PR scopes the suppression strictly to QuickBuy via inversion of control, so the main Swap/Bridge flows are unaffected: - New dependency-free module `app/core/notificationSkipPredicates.ts` holds a registry of "skip predicates". `NotificationManager.#shouldSkipNotification` consults it (guarded by try/catch so a throwing predicate can never break notifications). Keeping it in its own module means feature code can register without importing the heavy `NotificationManager` graph. - QuickBuy registers a predicate at the app root (`useQuickBuyToastRegistrations`) that matches: - **tracked trade ids** — covers the terminal complete/failed toast, and - **in-flight submissions** via a pre-submit marker (`beginQuickBuySubmission`/`endQuickBuySubmission`) — covers the *pending* toast, which fires mid-`submitTx` before the tx id is known. ## **Changelog** CHANGELOG entry: Fixed QuickBuy showing duplicate transaction toasts for smart-account and gas-included trades ## **Related issues** Fixes: https://consensyssoftware.atlassian.net/browse/TSA-627 ## **Manual testing steps** ```gherkin Feature: QuickBuy transaction toasts Scenario: User runs a QuickBuy with a smart (EIP-7702) account Given I have an EIP-7702 smart account enabled on the trade network And I open a token from the Social Leaderboard trader position view When I confirm a QuickBuy trade Then I see exactly one QuickBuy "Buying..." pending toast And after the trade settles I see exactly one QuickBuy "Bought" (or "Failed") toast And I do not see the generic "Transaction submitted" or "Transaction complete" toasts Scenario: User runs a QuickBuy on a plain EOA (regression) Given I have a standard externally-owned account When I confirm a QuickBuy trade Then I still see only the two QuickBuy toasts (pending, then terminal) Scenario: Main Swap/Bridge flows are unaffected (regression) Given I start a swap or bridge from the main Swap/Bridge UI When the transaction is submitted and confirmed Then the generic transaction toasts still appear as before ``` ## **Screenshots/Recordings** ### **Before** In some rare cases, this happens: https://github.com/user-attachments/assets/838826be-99a7-4101-873e-88173059fbc1 ### **After** This is what we want in all cases: https://github.com/user-attachments/assets/b49de0d5-a0e2-47cb-8eef-6b372ad0f79f ## **Pre-merge author checklist** - [x] I've followed [MetaMask Contributor Docs](https://github.com/MetaMask/contributor-docs) and [MetaMask Mobile Coding Standards](https://github.com/MetaMask/metamask-mobile/blob/main/.github/guidelines/CODING_GUIDELINES.md). - [x] I've completed the PR template to the best of my ability - [x] I've included tests if applicable - [x] I've documented my code using [JSDoc](https://jsdoc.app/) format if applicable - [x] I've applied the right labels on the PR (see [labeling guidelines](https://github.com/MetaMask/metamask-mobile/blob/main/.github/guidelines/LABELING_GUIDELINES.md)). Not required for external contributors. #### Performance checks (if applicable) - [x] I've tested on Android - Ideally on a mid-range device; emulator is acceptable - [x] I've tested with a power user scenario - Use these [power-user SRPs](https://consensyssoftware.atlassian.net/wiki/spaces/TL1/pages/edit-v2/401401446401?draftShareId=9d77e1e1-4bdc-4be1-9ebb-ccd916988d93) to import wallets with many accounts and tokens - [x] I've instrumented key operations with Sentry traces for production performance metrics - See [`trace()`](/app/util/trace.ts) for usage and [`addToken`](/app/components/Views/AddAsset/components/AddCustomToken/AddCustomToken.tsx#L274) for an example For performance guidelines and tooling, see the [Performance Guide](https://consensyssoftware.atlassian.net/wiki/spaces/TL1/pages/400085549067/Performance+Guide+for+Engineers). ## **Pre-merge reviewer checklist** - [ ] I've manually tested the PR (e.g. pull and build branch, run the app, test code being changed). - [ ] I confirm that this PR addresses all acceptance criteria described in the ticket it closes and includes the necessary testing evidence such as recordings and or screenshots. Made with [Cursor](https://cursor.com) --- > [!NOTE] > **Medium Risk** > Changes global notification gating and briefly suppresses swap/bridge/batch notifications during QuickBuy submit; predicates are isolated and errors are caught so unrelated flows should still get generic toasts. > > **Overview** > Adds a **dependency-free notification skip registry** (`notificationSkipPredicates`) and wires **`NotificationManager`** to consult registered predicates (with try/catch) so feature flows can opt out of generic transaction toasts without core importing UI code. > > **QuickBuy** registers `isQuickBuyTransaction` at the app root and extends the trade tracker with: a **ref-counted in-flight submission marker** (`begin`/`end` around `submitTx`) for pending notifications before the tx id exists; **tracked and recently-settled tx ids** so suppression still applies when `NotificationManager` re-checks ~2s after confirm/fail; and **type-narrowed matching** (swap/bridge/batch) during submission so unrelated txs are not hidden. Terminal handling now **settles** trades via `markQuickBuyTradeSettled` instead of a plain untrack so duplicate generic success/error toasts do not appear on top of QuickBuy’s own toasts (notably EIP-7702 `batch` trades). > > Reviewed by [Cursor Bugbot](https://cursor.com/bugbot) for commit 270b0ec43c43219f46c381f075040f4d88860545. Bugbot is set up for automated code reviews on this repo. Configure [here](https://www.cursor.com/dashboard/bugbot). --------- Co-authored-by: Cursor --- .../QuickBuy/hooks/useQuickBuyController.ts | 15 +- .../useQuickBuyToastRegistrations.test.tsx | 27 +++ .../hooks/useQuickBuyToastRegistrations.tsx | 13 +- .../QuickBuy/quickBuyTradeTracker.test.ts | 159 ++++++++++++++++++ .../QuickBuy/quickBuyTradeTracker.ts | 124 ++++++++++++++ .../resolveQuickBuyTerminalToast.test.ts | 20 +++ .../QuickBuy/resolveQuickBuyTerminalToast.ts | 12 +- .../QuickBuy/useQuickBuyController.test.ts | 52 +++++- app/core/NotificationManager.js | 28 ++- app/core/NotificationsManager.test.ts | 80 +++++++++ app/core/notificationSkipPredicates.ts | 40 +++++ 11 files changed, 560 insertions(+), 10 deletions(-) create mode 100644 app/core/notificationSkipPredicates.ts diff --git a/app/components/Views/SocialLeaderboard/TraderPositionView/components/QuickBuy/hooks/useQuickBuyController.ts b/app/components/Views/SocialLeaderboard/TraderPositionView/components/QuickBuy/hooks/useQuickBuyController.ts index 8a1f5c14ea4..3d5ba9fae1a 100644 --- a/app/components/Views/SocialLeaderboard/TraderPositionView/components/QuickBuy/hooks/useQuickBuyController.ts +++ b/app/components/Views/SocialLeaderboard/TraderPositionView/components/QuickBuy/hooks/useQuickBuyController.ts @@ -95,7 +95,11 @@ import { SocialLeaderboardEventValues, } from '../../../../analytics'; import { buildQuickBuyToastOptions } from '../quickBuyToastOptions'; -import { trackQuickBuyTrade } from '../quickBuyTradeTracker'; +import { + trackQuickBuyTrade, + beginQuickBuySubmission, + endQuickBuySubmission, +} from '../quickBuyTradeTracker'; import { resolveQuickBuyTerminalToast } from '../resolveQuickBuyTerminalToast'; export type QuickBuyButtonError = @@ -1061,6 +1065,11 @@ export function useQuickBuyController( submitStartedAtRef.current ? Date.now() - submitStartedAtRef.current : 0; try { + // Mark a QuickBuy submission as in flight BEFORE submitTx so the generic + // transaction notification (which fires mid-submit, before the tx id is + // known) is suppressed. The id-based tracker takes over for the terminal + // notification once submitTx resolves. + beginQuickBuySubmission(); dispatch(setIsSubmittingTx(true)); const submitResult = await Engine.context.BridgeStatusController.submitTx( walletAddress, @@ -1132,6 +1141,10 @@ export function useQuickBuyController( }); } } finally { + // Cleared after `trackQuickBuyTrade` (in the try block) has registered the + // tx id, so the predicate transitions from marker-based to id-based + // suppression with no coverage gap. + endQuickBuySubmission(); dispatch(setIsSubmittingTx(false)); } }, [ diff --git a/app/components/Views/SocialLeaderboard/TraderPositionView/components/QuickBuy/hooks/useQuickBuyToastRegistrations.test.tsx b/app/components/Views/SocialLeaderboard/TraderPositionView/components/QuickBuy/hooks/useQuickBuyToastRegistrations.test.tsx index 3e493a9dc2a..527433055ec 100644 --- a/app/components/Views/SocialLeaderboard/TraderPositionView/components/QuickBuy/hooks/useQuickBuyToastRegistrations.test.tsx +++ b/app/components/Views/SocialLeaderboard/TraderPositionView/components/QuickBuy/hooks/useQuickBuyToastRegistrations.test.tsx @@ -8,13 +8,21 @@ import { } from '../../../../../../../util/haptics'; import { buildQuickBuyToastOptions } from '../quickBuyToastOptions'; import { + clearSettledQuickBuyTrades, getTrackedQuickBuyTradeIds, + isQuickBuyTransaction, trackQuickBuyTrade, untrackQuickBuyTrade, type TrackedQuickBuyTrade, } from '../quickBuyTradeTracker'; +import { registerNotificationSkipPredicate } from '../../../../../../../core/notificationSkipPredicates'; import { useQuickBuyToastRegistrations } from './useQuickBuyToastRegistrations'; +jest.mock('../../../../../../../core/notificationSkipPredicates', () => ({ + __esModule: true, + registerNotificationSkipPredicate: jest.fn(() => jest.fn()), +})); + jest.mock('../quickBuyToastOptions', () => ({ buildQuickBuyToastOptions: jest.fn((kind: string) => ({ kind })), })); @@ -101,10 +109,29 @@ describe('useQuickBuyToastRegistrations', () => { beforeEach(() => { jest.clearAllMocks(); getTrackedQuickBuyTradeIds().forEach(untrackQuickBuyTrade); + clearSettledQuickBuyTrades(); Engine.context.MultichainTransactionsController.state.nonEvmTransactions = {}; }); + it('registers the QuickBuy notification skip predicate on mount and unregisters on unmount', () => { + const unregister = jest.fn(); + (registerNotificationSkipPredicate as jest.Mock).mockReturnValue( + unregister, + ); + + const { unmount } = renderHook(() => useQuickBuyToastRegistrations()); + + expect(registerNotificationSkipPredicate).toHaveBeenCalledWith( + isQuickBuyTransaction, + ); + expect(unregister).not.toHaveBeenCalled(); + + unmount(); + + expect(unregister).toHaveBeenCalledTimes(1); + }); + it('subscribes to both the bridge and multichain state change events', () => { const { result } = renderHook(() => useQuickBuyToastRegistrations()); diff --git a/app/components/Views/SocialLeaderboard/TraderPositionView/components/QuickBuy/hooks/useQuickBuyToastRegistrations.tsx b/app/components/Views/SocialLeaderboard/TraderPositionView/components/QuickBuy/hooks/useQuickBuyToastRegistrations.tsx index 49d92e2e431..cf2bcc97098 100644 --- a/app/components/Views/SocialLeaderboard/TraderPositionView/components/QuickBuy/hooks/useQuickBuyToastRegistrations.tsx +++ b/app/components/Views/SocialLeaderboard/TraderPositionView/components/QuickBuy/hooks/useQuickBuyToastRegistrations.tsx @@ -1,8 +1,12 @@ -import { useCallback, useMemo } from 'react'; +import { useCallback, useEffect, useMemo } from 'react'; import type { ToastRef } from '../../../../../../../component-library/components/Toast/Toast.types'; import { useAppThemeFromContext } from '../../../../../../../util/theme'; +import { registerNotificationSkipPredicate } from '../../../../../../../core/notificationSkipPredicates'; import type { ToastRegistration } from '../../../../../../Nav/App/ControllerEventToastBridge'; -import { getTrackedQuickBuyTradeIds } from '../quickBuyTradeTracker'; +import { + getTrackedQuickBuyTradeIds, + isQuickBuyTransaction, +} from '../quickBuyTradeTracker'; import { resolveQuickBuyTerminalToast } from '../resolveQuickBuyTerminalToast'; /** @@ -19,6 +23,11 @@ import { resolveQuickBuyTerminalToast } from '../resolveQuickBuyTerminalToast'; export const useQuickBuyToastRegistrations = (): ToastRegistration[] => { const theme = useAppThemeFromContext(); + // Opt QuickBuy-initiated transactions out of the generic transaction + // notifications so the user only sees QuickBuy's own toasts. Registered at + // the app root so it covers submissions regardless of sheet lifecycle. + useEffect(() => registerNotificationSkipPredicate(isQuickBuyTransaction), []); + // Shared by both controller subscriptions: `resolveQuickBuyTerminalToast` // checks each tracked trade against whichever controller is authoritative for // it, so we can fan out the same scan regardless of which event fired. diff --git a/app/components/Views/SocialLeaderboard/TraderPositionView/components/QuickBuy/quickBuyTradeTracker.test.ts b/app/components/Views/SocialLeaderboard/TraderPositionView/components/QuickBuy/quickBuyTradeTracker.test.ts index 2b336628600..d90c0d69f3b 100644 --- a/app/components/Views/SocialLeaderboard/TraderPositionView/components/QuickBuy/quickBuyTradeTracker.test.ts +++ b/app/components/Views/SocialLeaderboard/TraderPositionView/components/QuickBuy/quickBuyTradeTracker.test.ts @@ -1,11 +1,24 @@ import { + TransactionType, + type TransactionMeta, +} from '@metamask/transaction-controller'; +import { + beginQuickBuySubmission, + clearSettledQuickBuyTrades, + endQuickBuySubmission, getTrackedQuickBuyTrade, getTrackedQuickBuyTradeIds, + hasPendingQuickBuySubmission, + isQuickBuyTransaction, + markQuickBuyTradeSettled, trackQuickBuyTrade, untrackQuickBuyTrade, type TrackedQuickBuyTrade, } from './quickBuyTradeTracker'; +const txMeta = (overrides: Partial): TransactionMeta => + overrides as TransactionMeta; + const buyTrade: TrackedQuickBuyTrade = { tradeMode: 'buy', tokenSymbol: 'PEPE', @@ -24,6 +37,10 @@ const sellTrade: TrackedQuickBuyTrade = { describe('quickBuyTradeTracker', () => { afterEach(() => { getTrackedQuickBuyTradeIds().forEach(untrackQuickBuyTrade); + clearSettledQuickBuyTrades(); + while (hasPendingQuickBuySubmission()) { + endQuickBuySubmission(); + } }); it('stores and returns a tracked trade by tx meta id', () => { @@ -84,4 +101,146 @@ describe('quickBuyTradeTracker', () => { expect(() => untrackQuickBuyTrade('missing')).not.toThrow(); expect(getTrackedQuickBuyTradeIds()).toEqual(['tx-1']); }); + + describe('submission marker', () => { + it('reflects in-flight submissions via the counter', () => { + expect(hasPendingQuickBuySubmission()).toBe(false); + + beginQuickBuySubmission(); + expect(hasPendingQuickBuySubmission()).toBe(true); + + endQuickBuySubmission(); + expect(hasPendingQuickBuySubmission()).toBe(false); + }); + + it('stays active until all overlapping submissions end', () => { + beginQuickBuySubmission(); + beginQuickBuySubmission(); + + endQuickBuySubmission(); + expect(hasPendingQuickBuySubmission()).toBe(true); + + endQuickBuySubmission(); + expect(hasPendingQuickBuySubmission()).toBe(false); + }); + + it('never drops below zero on unbalanced ends', () => { + endQuickBuySubmission(); + + expect(hasPendingQuickBuySubmission()).toBe(false); + + beginQuickBuySubmission(); + expect(hasPendingQuickBuySubmission()).toBe(true); + }); + }); + + describe('isQuickBuyTransaction', () => { + it('matches a transaction already tracked by id', () => { + trackQuickBuyTrade('tx-1', buyTrade); + + expect(isQuickBuyTransaction(txMeta({ id: 'tx-1' }))).toBe(true); + }); + + it('matches an in-flight swap/bridge/batch transaction during submission', () => { + beginQuickBuySubmission(); + + expect( + isQuickBuyTransaction( + txMeta({ id: 'tx-9', type: TransactionType.batch }), + ), + ).toBe(true); + expect( + isQuickBuyTransaction( + txMeta({ id: 'tx-9', type: TransactionType.swap }), + ), + ).toBe(true); + expect( + isQuickBuyTransaction( + txMeta({ id: 'tx-9', type: TransactionType.bridge }), + ), + ).toBe(true); + }); + + it('matches a batch whose nested transaction is a swap during submission', () => { + beginQuickBuySubmission(); + + expect( + isQuickBuyTransaction( + txMeta({ + id: 'tx-9', + type: TransactionType.batch, + nestedTransactions: [{ type: TransactionType.swap }], + // eslint-disable-next-line @typescript-eslint/no-explicit-any + } as any), + ), + ).toBe(true); + }); + + it('does not match an unrelated in-flight transaction type', () => { + beginQuickBuySubmission(); + + expect( + isQuickBuyTransaction( + txMeta({ id: 'tx-9', type: TransactionType.simpleSend }), + ), + ).toBe(false); + }); + + it('does not match when there is no marker and no tracked id', () => { + expect( + isQuickBuyTransaction( + txMeta({ id: 'tx-9', type: TransactionType.swap }), + ), + ).toBe(false); + }); + + it('still matches a trade after it has settled (covers the delayed re-check)', () => { + trackQuickBuyTrade('tx-1', buyTrade); + markQuickBuyTradeSettled('tx-1'); + + expect(getTrackedQuickBuyTradeIds()).toEqual([]); + expect(isQuickBuyTransaction(txMeta({ id: 'tx-1' }))).toBe(true); + }); + + it('stops matching a settled trade once it is explicitly untracked', () => { + trackQuickBuyTrade('tx-1', buyTrade); + markQuickBuyTradeSettled('tx-1'); + untrackQuickBuyTrade('tx-1'); + + expect(isQuickBuyTransaction(txMeta({ id: 'tx-1' }))).toBe(false); + }); + }); + + describe('markQuickBuyTradeSettled', () => { + it('removes the trade from the active registry so the terminal toast does not repeat', () => { + trackQuickBuyTrade('tx-1', buyTrade); + + markQuickBuyTradeSettled('tx-1'); + + expect(getTrackedQuickBuyTrade('tx-1')).toBeUndefined(); + expect(getTrackedQuickBuyTradeIds()).toEqual([]); + }); + + it('evicts the oldest settled id once the retention cap is exceeded', () => { + for (let i = 0; i < 51; i += 1) { + markQuickBuyTradeSettled(`tx-${i}`); + } + + expect(isQuickBuyTransaction(txMeta({ id: 'tx-0' }))).toBe(false); + expect(isQuickBuyTransaction(txMeta({ id: 'tx-1' }))).toBe(true); + expect(isQuickBuyTransaction(txMeta({ id: 'tx-50' }))).toBe(true); + }); + + it('refreshes a re-settled id so it survives further eviction', () => { + markQuickBuyTradeSettled('keep-me'); + for (let i = 0; i < 49; i += 1) { + markQuickBuyTradeSettled(`tx-${i}`); + } + // Re-settle to move it to the newest position before overflowing. + markQuickBuyTradeSettled('keep-me'); + markQuickBuyTradeSettled('overflow'); + + expect(isQuickBuyTransaction(txMeta({ id: 'keep-me' }))).toBe(true); + }); + }); }); diff --git a/app/components/Views/SocialLeaderboard/TraderPositionView/components/QuickBuy/quickBuyTradeTracker.ts b/app/components/Views/SocialLeaderboard/TraderPositionView/components/QuickBuy/quickBuyTradeTracker.ts index 85cbd6f9c17..5806ad85934 100644 --- a/app/components/Views/SocialLeaderboard/TraderPositionView/components/QuickBuy/quickBuyTradeTracker.ts +++ b/app/components/Views/SocialLeaderboard/TraderPositionView/components/QuickBuy/quickBuyTradeTracker.ts @@ -1,3 +1,7 @@ +import { + TransactionType, + type TransactionMeta, +} from '@metamask/transaction-controller'; import type { QuickBuyTradeMode } from './types'; export interface TrackedQuickBuyTrade { @@ -41,6 +45,30 @@ const trackedTrades = new Map(); /** Shared empty result so the common "nothing tracked" path allocates nothing. */ const EMPTY_TRADE_IDS: string[] = []; +/** + * Ids of QuickBuy trades that have reached a terminal state and been untracked. + * + * The terminal handler untracks a trade the instant the swap settles, but + * `NotificationManager` re-checks the skip predicates ~2s *after* + * `transactionConfirmed`/`transactionFailed` (a delayed `setTimeout`). For a + * fast-settling QuickBuy the trade would already be gone from `trackedTrades` + * by then, so the generic success/error toast would leak on top of QuickBuy's + * own terminal toast. Remembering recently-settled ids keeps suppression alive + * across that window. + * + * Bounded (insertion-ordered FIFO eviction) so it can never grow unbounded; + * QuickBuy ids are globally-unique tx meta ids, so retaining a small recent + * history is harmless. + */ +const settledTradeIds = new Set(); + +/** + * Upper bound on remembered settled ids. Comfortably larger than any realistic + * number of QuickBuy trades settling within the ~2s notification re-check + * window, while keeping memory trivially small. + */ +const SETTLED_TRADE_ID_LIMIT = 50; + export function trackQuickBuyTrade( txMetaId: string, info: TrackedQuickBuyTrade, @@ -66,4 +94,100 @@ export function getTrackedQuickBuyTradeIds(): string[] { export function untrackQuickBuyTrade(txMetaId: string): void { trackedTrades.delete(txMetaId); + settledTradeIds.delete(txMetaId); +} + +/** + * Marks a trade as terminally settled: removes it from the active registry (so + * the terminal toast is not emitted twice) while remembering its id so the + * delayed generic-notification re-check still suppresses the leftover + * success/error toast. + */ +export function markQuickBuyTradeSettled(txMetaId: string): void { + trackedTrades.delete(txMetaId); + + // Re-insert to refresh FIFO position, then evict the oldest if over the cap. + settledTradeIds.delete(txMetaId); + settledTradeIds.add(txMetaId); + if (settledTradeIds.size > SETTLED_TRADE_ID_LIMIT) { + const oldest = settledTradeIds.values().next().value; + if (oldest !== undefined) { + settledTradeIds.delete(oldest); + } + } +} + +/** Clears all remembered settled ids. Intended for test isolation. */ +export function clearSettledQuickBuyTrades(): void { + settledTradeIds.clear(); +} + +/** + * Count of QuickBuy submissions currently in flight. + * + * The source transaction meta id is only known once `submitTx` resolves, but + * the generic transaction notification fires earlier (on `transactionApproved`, + * mid-submit). This marker lets us suppress that early notification before the + * id is registered. A counter (rather than a boolean) tolerates overlapping + * submissions without prematurely clearing the marker. + */ +let pendingSubmissions = 0; + +export function beginQuickBuySubmission(): void { + pendingSubmissions += 1; +} + +export function endQuickBuySubmission(): void { + pendingSubmissions = Math.max(0, pendingSubmissions - 1); +} + +export function hasPendingQuickBuySubmission(): boolean { + return pendingSubmissions > 0; +} + +// Transaction types a QuickBuy submission can produce. Used to narrow the +// marker window so an unrelated transaction approving mid-submit is not +// suppressed. `batch` is the 7702 wrapper that triggers the duplicate +// notification; swap/bridge cover the non-batched paths. +const QUICK_BUY_SUPPRESSED_TYPES: TransactionType[] = [ + TransactionType.batch, + TransactionType.swap, + TransactionType.bridge, +]; + +/** + * Predicate consumed by `NotificationManager` to skip the generic transaction + * notification for QuickBuy-initiated trades. Matches a transaction that is + * either currently tracked by id, recently settled by id (covers the delayed + * generic-toast re-check that fires after the trade is untracked), or — while a + * submission is in flight — a swap/bridge/batch transaction (covers the pending + * notification that fires before the id is known). + */ +export function isQuickBuyTransaction( + transactionMeta: TransactionMeta | undefined, +): boolean { + if (transactionMeta?.id) { + if (getTrackedQuickBuyTrade(transactionMeta.id)) { + return true; + } + if (settledTradeIds.has(transactionMeta.id)) { + return true; + } + } + + if (!hasPendingQuickBuySubmission()) { + return false; + } + + const { type, nestedTransactions } = transactionMeta ?? {}; + + if (type && QUICK_BUY_SUPPRESSED_TYPES.includes(type)) { + return true; + } + + return Boolean( + nestedTransactions?.some( + (tx) => tx.type && QUICK_BUY_SUPPRESSED_TYPES.includes(tx.type), + ), + ); } diff --git a/app/components/Views/SocialLeaderboard/TraderPositionView/components/QuickBuy/resolveQuickBuyTerminalToast.test.ts b/app/components/Views/SocialLeaderboard/TraderPositionView/components/QuickBuy/resolveQuickBuyTerminalToast.test.ts index afcdc42a394..c1cbb1927e2 100644 --- a/app/components/Views/SocialLeaderboard/TraderPositionView/components/QuickBuy/resolveQuickBuyTerminalToast.test.ts +++ b/app/components/Views/SocialLeaderboard/TraderPositionView/components/QuickBuy/resolveQuickBuyTerminalToast.test.ts @@ -7,7 +7,9 @@ import { } from '../../../../../../util/haptics'; import { buildQuickBuyToastOptions } from './quickBuyToastOptions'; import { + clearSettledQuickBuyTrades, getTrackedQuickBuyTradeIds, + isQuickBuyTransaction, trackQuickBuyTrade, untrackQuickBuyTrade, type TrackedQuickBuyTrade, @@ -83,6 +85,7 @@ describe('resolveQuickBuyTerminalToast', () => { beforeEach(() => { jest.clearAllMocks(); getTrackedQuickBuyTradeIds().forEach(untrackQuickBuyTrade); + clearSettledQuickBuyTrades(); Engine.context.MultichainTransactionsController.state.nonEvmTransactions = {}; }); @@ -160,6 +163,23 @@ describe('resolveQuickBuyTerminalToast', () => { expect(showToast).toHaveBeenCalledTimes(1); }); + it('keeps the trade recognised as QuickBuy after settling so the delayed generic toast stays suppressed', () => { + trackQuickBuyTrade('tx-1', buyTrade); + mockGetHistoryItem.mockReturnValue( + historyItemWithStatus(StatusTypes.COMPLETE), + ); + + resolveQuickBuyTerminalToast('tx-1', jest.fn(), theme); + + expect(getTrackedQuickBuyTradeIds()).toEqual([]); + expect( + isQuickBuyTransaction({ + id: 'tx-1', + // eslint-disable-next-line @typescript-eslint/no-explicit-any + } as any), + ).toBe(true); + }); + it('resolves a Solana swap to complete from the multichain controller when bridge status is absent', () => { trackQuickBuyTrade('sig-1', solanaTrade); mockGetHistoryItem.mockReturnValue(undefined); diff --git a/app/components/Views/SocialLeaderboard/TraderPositionView/components/QuickBuy/resolveQuickBuyTerminalToast.ts b/app/components/Views/SocialLeaderboard/TraderPositionView/components/QuickBuy/resolveQuickBuyTerminalToast.ts index 8b5de26aea2..e2f66b0b045 100644 --- a/app/components/Views/SocialLeaderboard/TraderPositionView/components/QuickBuy/resolveQuickBuyTerminalToast.ts +++ b/app/components/Views/SocialLeaderboard/TraderPositionView/components/QuickBuy/resolveQuickBuyTerminalToast.ts @@ -10,7 +10,7 @@ import type { Theme } from '../../../../../../util/theme/models'; import { buildQuickBuyToastOptions } from './quickBuyToastOptions'; import { getTrackedQuickBuyTrade, - untrackQuickBuyTrade, + markQuickBuyTradeSettled, type TrackedQuickBuyTrade, } from './quickBuyTradeTracker'; @@ -65,11 +65,13 @@ function resolveFromMultichain(signature: string): TerminalOutcome | undefined { } /** - * Claims the trade atomically (untrack doubles as the dedupe), surfaces the + * Claims the trade atomically (settling doubles as the dedupe), surfaces the * matching `complete` / `failed` toast and fires the paired haptic. The first - * caller to reach a terminal status removes the trade, so any later + * caller to reach a terminal status settles the trade, so any later * `stateChange` emission — or a concurrent resolve from the other controller — - * becomes a no-op. + * becomes a no-op. Settling (rather than a plain untrack) keeps the id known to + * `isQuickBuyTransaction` so the delayed generic success/error toast is still + * suppressed. */ function emitTerminalToast( txMetaId: string, @@ -78,7 +80,7 @@ function emitTerminalToast( showToast: ToastRef['showToast'], theme: Theme, ): boolean { - untrackQuickBuyTrade(txMetaId); + markQuickBuyTradeSettled(txMetaId); const isComplete = outcome === 'complete'; showToast( diff --git a/app/components/Views/SocialLeaderboard/TraderPositionView/components/QuickBuy/useQuickBuyController.test.ts b/app/components/Views/SocialLeaderboard/TraderPositionView/components/QuickBuy/useQuickBuyController.test.ts index 102dbf28375..2b2d50b5035 100644 --- a/app/components/Views/SocialLeaderboard/TraderPositionView/components/QuickBuy/useQuickBuyController.test.ts +++ b/app/components/Views/SocialLeaderboard/TraderPositionView/components/QuickBuy/useQuickBuyController.test.ts @@ -40,7 +40,11 @@ import { import { useQuickBuySetup } from './hooks/useQuickBuySetup'; import { useReceiveTokens } from './hooks/useReceiveTokens'; import { buildQuickBuyToastOptions } from './quickBuyToastOptions'; -import { trackQuickBuyTrade } from './quickBuyTradeTracker'; +import { + trackQuickBuyTrade, + beginQuickBuySubmission, + endQuickBuySubmission, +} from './quickBuyTradeTracker'; import { resolveQuickBuyTerminalToast } from './resolveQuickBuyTerminalToast'; import { positionToQuickBuyTarget } from './types'; @@ -233,6 +237,8 @@ jest.mock('./quickBuyTradeTracker', () => ({ getTrackedQuickBuyTrade: jest.fn(), getTrackedQuickBuyTradeIds: jest.fn(() => []), untrackQuickBuyTrade: jest.fn(), + beginQuickBuySubmission: jest.fn(), + endQuickBuySubmission: jest.fn(), })); jest.mock('./quickBuyToastOptions', () => ({ @@ -2418,6 +2424,50 @@ describe('useQuickBuyController', () => { expect(playErrorNotification).toHaveBeenCalledTimes(1); expect(trackQuickBuyTrade).not.toHaveBeenCalled(); }); + + it('marks the submission in flight before submit and clears it after success', async () => { + mockUsableQuote(); + ( + Engine.context.BridgeStatusController.submitTx as jest.Mock + ).mockResolvedValue({ id: 'tx-1', hash: '0xabc' }); + + const { result } = renderHook(() => + useQuickBuyController(createTarget(), jest.fn()), + ); + + await act(async () => { + await result.current.handleConfirm(); + }); + + expect(beginQuickBuySubmission).toHaveBeenCalledTimes(1); + expect(endQuickBuySubmission).toHaveBeenCalledTimes(1); + const beginOrder = (beginQuickBuySubmission as jest.Mock).mock + .invocationCallOrder[0]; + const trackOrder = (trackQuickBuyTrade as jest.Mock).mock + .invocationCallOrder[0]; + const endOrder = (endQuickBuySubmission as jest.Mock).mock + .invocationCallOrder[0]; + expect(beginOrder).toBeLessThan(trackOrder); + expect(trackOrder).toBeLessThan(endOrder); + }); + + it('clears the in-flight submission marker when submit throws', async () => { + mockUsableQuote(); + ( + Engine.context.BridgeStatusController.submitTx as jest.Mock + ).mockRejectedValue(new Error('user rejected')); + + const { result } = renderHook(() => + useQuickBuyController(createTarget(), jest.fn()), + ); + + await act(async () => { + await result.current.handleConfirm(); + }); + + expect(beginQuickBuySubmission).toHaveBeenCalledTimes(1); + expect(endQuickBuySubmission).toHaveBeenCalledTimes(1); + }); }); }); }); diff --git a/app/core/NotificationManager.js b/app/core/NotificationManager.js index 2a45aaa4015..dd922aa602d 100644 --- a/app/core/NotificationManager.js +++ b/app/core/NotificationManager.js @@ -21,6 +21,7 @@ import { import { endTrace, trace, TraceName } from '../util/trace'; import { hasTransactionType } from '../components/Views/confirmations/utils/transaction'; import TransactionTypes from './TransactionTypes'; +import { getNotificationSkipPredicates } from './notificationSkipPredicates'; export const SKIP_NOTIFICATION_TRANSACTION_TYPES = [ TransactionType.moneyAccountDeposit, @@ -43,6 +44,14 @@ export const IN_PROGRESS_SKIP_STATUS = [ TransactionStatus.submitted, ]; +// Re-exported for convenience. The registry lives in its own dependency-free +// module (`notificationSkipPredicates`) so feature code can register a predicate +// without importing this heavy module and its transitive store/saga graph. +export { + registerNotificationSkipPredicate, + clearNotificationSkipPredicates, +} from './notificationSkipPredicates'; + export const constructTitleAndMessage = (notification) => { let title, message; switch (notification.type) { @@ -586,7 +595,24 @@ class NotificationManager { tx.batchId === transactionMeta?.batchId, ); - return isSameBatch; + if (isSameBatch) { + return true; + } + + // Feature-registered predicates (e.g. QuickBuy surfaces its own toasts and + // opts its transactions out of the generic notification). A throwing + // predicate must never break notifications. + for (const predicate of getNotificationSkipPredicates()) { + try { + if (predicate(transactionMeta)) { + return true; + } + } catch (error) { + Logger.error(error, 'Notification skip predicate threw'); + } + } + + return false; } } diff --git a/app/core/NotificationsManager.test.ts b/app/core/NotificationsManager.test.ts index 73abedcd441..1bf777b1dc0 100644 --- a/app/core/NotificationsManager.test.ts +++ b/app/core/NotificationsManager.test.ts @@ -3,7 +3,9 @@ import { NotificationTransactionTypes } from '../util/notifications'; import NotificationManager, { IN_PROGRESS_SKIP_STATUS, SKIP_NOTIFICATION_TRANSACTION_TYPES, + clearNotificationSkipPredicates, constructTitleAndMessage, + registerNotificationSkipPredicate, } from './NotificationManager'; import { strings } from '../../locales/i18n'; import { SmartTransactionStatuses } from '@metamask/smart-transactions-controller'; @@ -778,5 +780,83 @@ describe('NotificationManager', () => { expect(showNotificationSpy).not.toHaveBeenCalled(); }); + + describe('notification skip predicates', () => { + afterEach(() => { + clearNotificationSkipPredicates(); + }); + + it('does not show a notification when a registered predicate matches', () => { + registerNotificationSkipPredicate(() => true); + + notificationManager.watchSubmittedTransaction({ + id: '0x123', + txParams: { nonce: '0x1' }, + silent: false, + }); + + expect(showNotificationSpy).not.toHaveBeenCalled(); + }); + + it('shows a notification when no registered predicate matches', () => { + registerNotificationSkipPredicate(() => false); + + notificationManager.watchSubmittedTransaction({ + id: '0x123', + txParams: { nonce: '0x1' }, + silent: false, + }); + + expect(showNotificationSpy).toHaveBeenCalledWith( + expect.objectContaining({ type: 'pending' }), + ); + }); + + it('ignores a throwing predicate and still shows the notification', () => { + registerNotificationSkipPredicate(() => { + throw new Error('predicate boom'); + }); + + notificationManager.watchSubmittedTransaction({ + id: '0x123', + txParams: { nonce: '0x1' }, + silent: false, + }); + + expect(showNotificationSpy).toHaveBeenCalledWith( + expect.objectContaining({ type: 'pending' }), + ); + }); + + it('passes the transaction meta to the predicate', () => { + const predicate = jest.fn(() => false); + registerNotificationSkipPredicate(predicate); + + notificationManager.watchSubmittedTransaction({ + id: '0x123', + txParams: { nonce: '0x1' }, + silent: false, + }); + + expect(predicate).toHaveBeenCalledWith( + expect.objectContaining({ id: '0x123' }), + ); + }); + + it('stops showing notifications only while the predicate is registered', () => { + const unregister = registerNotificationSkipPredicate(() => true); + unregister(); + + notificationManager.watchSubmittedTransaction({ + id: '0x123', + txParams: { nonce: '0x1' }, + silent: false, + }); + + expect(showNotificationSpy).toHaveBeenCalledWith( + expect.objectContaining({ type: 'pending' }), + ); + }); + }); }); }); diff --git a/app/core/notificationSkipPredicates.ts b/app/core/notificationSkipPredicates.ts new file mode 100644 index 00000000000..d88fc31f9e7 --- /dev/null +++ b/app/core/notificationSkipPredicates.ts @@ -0,0 +1,40 @@ +import type { TransactionMeta } from '@metamask/transaction-controller'; + +/** + * A predicate that, given a transaction, returns `true` when the generic + * transaction notification should be suppressed for it. + */ +export type NotificationSkipPredicate = ( + transactionMeta: TransactionMeta | undefined, +) => boolean; + +/** + * Registry of predicates that let feature code opt a transaction out of the + * generic transaction notifications, without `NotificationManager` (core) + * depending on any feature module. + * + * This lives in its own dependency-free module so feature code can register a + * predicate without importing the heavy `NotificationManager` module (and its + * transitive store/saga graph). + */ +const notificationSkipPredicates = new Set(); + +/** + * Register a predicate. Returns an unregister function. + */ +export function registerNotificationSkipPredicate( + predicate: NotificationSkipPredicate, +): () => void { + notificationSkipPredicates.add(predicate); + return () => { + notificationSkipPredicates.delete(predicate); + }; +} + +export function clearNotificationSkipPredicates(): void { + notificationSkipPredicates.clear(); +} + +export function getNotificationSkipPredicates(): NotificationSkipPredicate[] { + return Array.from(notificationSkipPredicates); +}