From 5744d47bc842efee3a01f49d79f0787061fd4b75 Mon Sep 17 00:00:00 2001 From: CW Date: Wed, 6 May 2026 00:17:02 -0700 Subject: [PATCH 1/9] test(e2e): mock test-dapp fox SVG and empty ALLOWLISTED_URLS (MMQA-1786) (#29754) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ## **Description** Empties `ALLOWLISTED_URLS` in `tests/api-mocking/mock-e2e-allowlist.ts` by mocking the last remaining entry — the test-dapp's hardcoded `metamask-fox.svg` URL. After [MMQA-1785](https://consensyssoftware.atlassian.net/browse/MMQA-1785), `ALLOWLISTED_URLS` was down to one URL. This PR drops it to zero by adding a default static-assets mock and deleting the entry. Cosmetic milestone for parent epic [MMQA-1364](https://consensyssoftware.atlassian.net/browse/MMQA-1364) (epic AC: *"`ALLOWLISTED_URLS` is empty"*). **Context — why no runtime change:** - `metamask.github.io` is still in `ALLOWLISTED_HOSTS` (driven separately by [MMQA-1367](https://consensyssoftware.atlassian.net/browse/MMQA-1367)). Hosts are matched first in `MockServerE2E.ts`, so the URL entry was redundant in practice. - The local DappServer serves `node_modules/@metamask/test-dapp/dist/`, which contains a local `metamask-fox.svg`. The HTML references it relatively (``), so normal test-dapp loads never hit GitHub. - The hardcoded `https://metamask.github.io/test-dapp/metamask-fox.svg` only appears inside the test-dapp's `wallet_watchAsset` button (sample token image). Specs that exercise "Add Token" trigger that fetch — the new mock now intercepts it with a minimal SVG response. ## **Changelog** CHANGELOG entry: null ## **Related issues** [MMQA-1786](https://consensyssoftware.atlassian.net/browse/MMQA-1786) Parent epic: [MMQA-1364](https://consensyssoftware.atlassian.net/browse/MMQA-1364) Fixes: ## **Manual testing steps** ```gherkin Feature: Empty ALLOWLISTED_URLS via test-dapp fox SVG mock Scenario: ALLOWLISTED_URLS is empty after this change Given the e2e test infrastructure is running When MockServerE2E starts up Then ALLOWLISTED_URLS in mock-e2e-allowlist.ts contains zero entries Scenario: test-dapp wallet_watchAsset flow uses mocked SVG Given a spec exercises the Add Token button on the test-dapp When the test-dapp performs wallet_watchAsset with the metamask-fox.svg image URL Then the request to https://metamask.github.io/test-dapp/metamask-fox.svg is answered by the static-assets default mock with a minimal SVG And no live request leaks per validateLiveRequests() ``` ## **Screenshots/Recordings** ### **Before** `tests/api-mocking/mock-e2e-allowlist.ts`: ```ts export const ALLOWLISTED_URLS = [ // Temporarily allow existing live requests during migration 'https://metamask.github.io/test-dapp/metamask-fox.svg', ]; ``` ### **After** `tests/api-mocking/mock-e2e-allowlist.ts`: ```ts export const ALLOWLISTED_URLS: string[] = []; ``` `tests/api-mocking/mock-responses/defaults/static-assets.ts` — new GET matcher serving `MINIMAL_SVG`: ```ts { urlEndpoint: /^https:\/\/metamask\.github\.io\/test-dapp\/metamask-fox\.svg$/, responseCode: 200, response: MINIMAL_SVG, }, ``` CI verification will be added once smoke tests complete on this PR. ## **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) - [ ] I've tested on Android - Ideally on a mid-range device; emulator is acceptable - [ ] 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 - [ ] 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. [MMQA-1785]: https://consensyssoftware.atlassian.net/browse/MMQA-1785?atlOrigin=eyJpIjoiNWRkNTljNzYxNjVmNDY3MDlhMDU5Y2ZhYzA5YTRkZjUiLCJwIjoiZ2l0aHViLWNvbS1KU1cifQ [MMQA-1364]: https://consensyssoftware.atlassian.net/browse/MMQA-1364?atlOrigin=eyJpIjoiNWRkNTljNzYxNjVmNDY3MDlhMDU5Y2ZhYzA5YTRkZjUiLCJwIjoiZ2l0aHViLWNvbS1KU1cifQ [MMQA-1367]: https://consensyssoftware.atlassian.net/browse/MMQA-1367?atlOrigin=eyJpIjoiNWRkNTljNzYxNjVmNDY3MDlhMDU5Y2ZhYzA5YTRkZjUiLCJwIjoiZ2l0aHViLWNvbS1KU1cifQ [MMQA-1786]: https://consensyssoftware.atlassian.net/browse/MMQA-1786?atlOrigin=eyJpIjoiNWRkNTljNzYxNjVmNDY3MDlhMDU5Y2ZhYzA5YTRkZjUiLCJwIjoiZ2l0aHViLWNvbS1KU1cifQ --- > [!NOTE] > **Low Risk** > Low risk: only affects e2e API-mocking configuration by replacing a previously allowlisted live URL with a deterministic mock response. > > **Overview** > Removes the last entry from `ALLOWLISTED_URLS` in e2e mocking so *no full URLs are explicitly allowed* to hit live servers. > > Adds a static-assets default GET mock for `https://metamask.github.io/test-dapp/metamask-fox.svg`, returning `MINIMAL_SVG`, so tests that fetch the test-dapp’s token image are handled entirely by the mock server. > > Reviewed by [Cursor Bugbot](https://cursor.com/bugbot) for commit f5bb7bc8fa513fbc2fdb056fa41d6def41b024a1. 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.7 (1M context) --- tests/api-mocking/mock-e2e-allowlist.ts | 5 +---- tests/api-mocking/mock-responses/defaults/static-assets.ts | 6 ++++++ 2 files changed, 7 insertions(+), 4 deletions(-) diff --git a/tests/api-mocking/mock-e2e-allowlist.ts b/tests/api-mocking/mock-e2e-allowlist.ts index 13e69847e37..7e24800b2a1 100644 --- a/tests/api-mocking/mock-e2e-allowlist.ts +++ b/tests/api-mocking/mock-e2e-allowlist.ts @@ -15,7 +15,4 @@ export const ALLOWLISTED_HOSTS = [ 'metamask.github.io', // Test-snaps and test-dapp pages loaded in browser ]; -export const ALLOWLISTED_URLS = [ - // Temporarily allow existing live requests during migration - 'https://metamask.github.io/test-dapp/metamask-fox.svg', -]; +export const ALLOWLISTED_URLS: string[] = []; diff --git a/tests/api-mocking/mock-responses/defaults/static-assets.ts b/tests/api-mocking/mock-responses/defaults/static-assets.ts index 939cc679b9a..377bf3ac28f 100644 --- a/tests/api-mocking/mock-responses/defaults/static-assets.ts +++ b/tests/api-mocking/mock-responses/defaults/static-assets.ts @@ -23,5 +23,11 @@ export const STATIC_ASSETS_MOCKS: MockEventsObject = { responseCode: 200, response: MINIMAL_SVG, }, + { + urlEndpoint: + /^https:\/\/metamask\.github\.io\/test-dapp\/metamask-fox\.svg$/, + responseCode: 200, + response: MINIMAL_SVG, + }, ], }; From fe942cf68c97a21b656e523e892540c7b9cd80d8 Mon Sep 17 00:00:00 2001 From: CW Date: Wed, 6 May 2026 00:18:01 -0700 Subject: [PATCH 2/9] test(e2e): remove Tenderly hosts from E2E allowlist (MMQA-1787) (#29760) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ## **Description** Removes all four Tenderly host entries from `ALLOWLISTED_HOSTS` in `tests/api-mocking/mock-e2e-allowlist.ts`. Parent epic [MMQA-1364](https://consensyssoftware.atlassian.net/browse/MMQA-1364). The parent epic carved Tenderly out of scope on the assumption that fork-based tests required live Tenderly virtual networks for contract simulation. Re-investigation showed that assumption no longer holds. **Disposition per host:** | Entry | Disposition | | --- | --- | | `api.tenderly.co` | Delete — zero references in tests/app code | | `rpc.tenderly.co` | Delete — zero references in tests/app code | | `virtual.linea.rpc.tenderly.co` | Delete — already mocked by `cardholder-mocks.ts:247-268` for the only specs that exercise it (`card-home-add-funds.spec.ts`, `card-button.spec.ts`) | | `virtual.mainnet.rpc.tenderly.co` | Default RPC mock added with regex matcher (URL has UUID path); returns `0x0` to standard JSON-RPC methods, mirroring the avax / zksync entries from [MMQA-1785](https://consensyssoftware.atlassian.net/browse/MMQA-1785) | **Bonus cleanup:** `tests/helpers/tenderly/tenderly.js` is deleted. Its only function (`Tenderly.addFunds()` calling `tenderly_setBalance`) was never called from anywhere in the codebase — that helper was the original reason for the Tenderly carve-out, and it's dead code. The Detox blacklist entry `.*rpc.tenderly.co/.*` in `blacklistURLs.json` is also dropped — those URLs are now intercepted by the mock server. After this lands, `ALLOWLISTED_HOSTS` is down to **7** entries (4 local + Polymarket carve-out + `metamask.github.io`). ## **Changelog** CHANGELOG entry: null ## **Related issues** [MMQA-1787](https://consensyssoftware.atlassian.net/browse/MMQA-1787) Parent epic: [MMQA-1364](https://consensyssoftware.atlassian.net/browse/MMQA-1364) Fixes: ## **Manual testing steps** ```gherkin Feature: E2E mock coverage for Tenderly virtual networks Scenario: on-ramp specs that load Tenderly Mainnet no longer leak live requests Given the on-ramp spec onramp-unified-buy.spec.ts is run And it builds a fixture with withNetworkController(CustomNetworks.Tenderly.Mainnet.providerConfig) When the wallet boots and the NetworkController initializes Then requests to virtual.mainnet.rpc.tenderly.co are answered by the default RPC mock And no entry for that host is required in mock-e2e-allowlist.ts Scenario: card specs continue to pass with Tenderly Linea host removed from allowlist Given the card spec card-home-add-funds.spec.ts is run When the wallet wires Tenderly.Linea and the test exercises the card flow Then RPC calls to virtual.linea.rpc.tenderly.co are intercepted by cardholder-mocks.ts And the spec passes without a live request leak Scenario: removed Tenderly helper does not break compilation Given tests/helpers/tenderly/tenderly.js has been deleted When the test suite is type-checked and built Then there are no broken import references ``` ## **Screenshots/Recordings** ### **Before** `tests/api-mocking/mock-e2e-allowlist.ts`: ```ts export const ALLOWLISTED_HOSTS = [ '0.0.0.0', '127.0.0.1', 'localhost', '10.0.2.2', 'api.tenderly.co', 'rpc.tenderly.co', 'virtual.mainnet.rpc.tenderly.co', 'virtual.linea.rpc.tenderly.co', 'gamma-api.polymarket.com', '*.polymarket.com', 'metamask.github.io', ]; ``` `tests/resources/blacklistURLs.json` — Detox dropping the host at the network layer: ``` ".*rpc.tenderly.co/.*" ``` `tests/helpers/tenderly/tenderly.js` — dead helper class with `Tenderly.addFunds()`. ### **After** `tests/api-mocking/mock-e2e-allowlist.ts` — four hosts removed: ```ts export const ALLOWLISTED_HOSTS = [ '0.0.0.0', '127.0.0.1', 'localhost', '10.0.2.2', 'gamma-api.polymarket.com', '*.polymarket.com', 'metamask.github.io', ]; ``` `tests/api-mocking/mock-responses/defaults/rpc-endpoints.ts` — new entry for Tenderly mainnet: ```ts { urlEndpoint: /^https:\/\/virtual\.mainnet\.rpc\.tenderly\.co\/.+$/, responseCode: 200, response: { jsonrpc: '2.0', id: 1, result: '0x0' }, }, ``` `tests/resources/blacklistURLs.json` — Tenderly entry removed. `tests/helpers/tenderly/tenderly.js` — file deleted. CI verification will be added once smoke tests complete on this PR. ## **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) - [ ] I've tested on Android - Ideally on a mid-range device; emulator is acceptable - [ ] 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 - [ ] 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. [MMQA-1364]: https://consensyssoftware.atlassian.net/browse/MMQA-1364?atlOrigin=eyJpIjoiNWRkNTljNzYxNjVmNDY3MDlhMDU5Y2ZhYzA5YTRkZjUiLCJwIjoiZ2l0aHViLWNvbS1KU1cifQ [MMQA-1785]: https://consensyssoftware.atlassian.net/browse/MMQA-1785?atlOrigin=eyJpIjoiNWRkNTljNzYxNjVmNDY3MDlhMDU5Y2ZhYzA5YTRkZjUiLCJwIjoiZ2l0aHViLWNvbS1KU1cifQ --- > [!NOTE] > **Low Risk** > Low risk test-infrastructure change that tightens E2E network mocking; main risk is unintended interception/mismatch of Tenderly RPC URLs causing E2E failures. > > **Overview** > Removes all Tenderly domains from the E2E `ALLOWLISTED_HOSTS`, reducing live-network carve-outs. > > Adds a default RPC mock for `virtual.mainnet.rpc.tenderly.co` using a regex URL matcher, deletes the unused `tests/helpers/tenderly/tenderly.js` helper, and drops the Detox `.*rpc.tenderly.co/.*` blacklist entry so these requests are handled by the mock server instead. > > Reviewed by [Cursor Bugbot](https://cursor.com/bugbot) for commit 485d840f28820a34e82b86992dd398f68017d4fe. 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.7 (1M context) --- tests/api-mocking/mock-e2e-allowlist.ts | 4 --- .../mock-responses/defaults/rpc-endpoints.ts | 9 +++++++ tests/helpers/tenderly/tenderly.js | 26 ------------------- tests/resources/blacklistURLs.json | 1 - 4 files changed, 9 insertions(+), 31 deletions(-) delete mode 100644 tests/helpers/tenderly/tenderly.js diff --git a/tests/api-mocking/mock-e2e-allowlist.ts b/tests/api-mocking/mock-e2e-allowlist.ts index 7e24800b2a1..9749085bc6c 100644 --- a/tests/api-mocking/mock-e2e-allowlist.ts +++ b/tests/api-mocking/mock-e2e-allowlist.ts @@ -6,10 +6,6 @@ export const ALLOWLISTED_HOSTS = [ '127.0.0.1', 'localhost', '10.0.2.2', // Android emulator host - 'api.tenderly.co', - 'rpc.tenderly.co', - 'virtual.mainnet.rpc.tenderly.co', - 'virtual.linea.rpc.tenderly.co', 'gamma-api.polymarket.com', '*.polymarket.com', 'metamask.github.io', // Test-snaps and test-dapp pages loaded in browser diff --git a/tests/api-mocking/mock-responses/defaults/rpc-endpoints.ts b/tests/api-mocking/mock-responses/defaults/rpc-endpoints.ts index 93b11ef3525..fd003339282 100644 --- a/tests/api-mocking/mock-responses/defaults/rpc-endpoints.ts +++ b/tests/api-mocking/mock-responses/defaults/rpc-endpoints.ts @@ -42,5 +42,14 @@ export const DEFAULT_RPC_ENDPOINT_MOCKS: MockEventsObject = { result: '0x0', }, }, + { + urlEndpoint: /^https:\/\/virtual\.mainnet\.rpc\.tenderly\.co\/.+$/, + responseCode: 200, + response: { + jsonrpc: '2.0', + id: 1, + result: '0x0', + }, + }, ], }; diff --git a/tests/helpers/tenderly/tenderly.js b/tests/helpers/tenderly/tenderly.js deleted file mode 100644 index db5ca8a501a..00000000000 --- a/tests/helpers/tenderly/tenderly.js +++ /dev/null @@ -1,26 +0,0 @@ -import axios from 'axios'; - -export default class Tenderly { - static async addFunds(rpcURL, account, amount = '0xDE0B6B3A764000000') { - const data = { - jsonrpc: '2.0', - method: 'tenderly_setBalance', - params: [[account], amount], - id: '1234', - }; - - const response = await axios.post(rpcURL, data, { - headers: { - 'Content-Type': 'application/json', - }, - }); - - if (response.data.error) { - // eslint-disable-next-line no-console - console.log( - `ERROR: Failed to add funds to Tenderly VirtualTestNet\n${response.data.error}`, - ); - return null; - } - } -} diff --git a/tests/resources/blacklistURLs.json b/tests/resources/blacklistURLs.json index 7458fdfd8e1..6241cb3c04c 100644 --- a/tests/resources/blacklistURLs.json +++ b/tests/resources/blacklistURLs.json @@ -6,7 +6,6 @@ ".*api.etherscan.io/.*", ".*static.metafi.codefi.network/.*", ".*static.cx.metamask.io/.*", - ".*rpc.tenderly.co/.*", ".*api-goerli.etherscan.io/.*", ".*gateway.pinata.cloud/.*", ".*stale.*", From 036608e84dc5d49e19fdaf4240e8742c74897131 Mon Sep 17 00:00:00 2001 From: Vinicius Stevam <45455812+vinistevam@users.noreply.github.com> Date: Wed, 6 May 2026 08:57:47 +0100 Subject: [PATCH 3/9] feat: first-time recipient alert on Send flow (#28650) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ## **Description** This change aligns MetaMask Mobile’s Send flow with the extension by surfacing **first-time recipient interaction** and **token contract** warnings before the user continues to confirmation. **Reason:** Users should be explicitly warned when sending to an address they have not interacted with on-chain before, and when the recipient looks like a token contract, so they can double-check the destination. **Solution:** - Added `checkFirstTimeInteraction` in `app/util/transaction-controller/index.ts`, using `getAccountAddressRelationship` from the transaction-controller preview package so semantics match the extension. - Introduced send-flow alert hooks (`useFirstTimeInteractionSendAlert`, `useTokenContractSendAlert`) and an aggregator `useSendAlerts` that orders alerts (token contract first, then first-time interaction). - Refactored `SendAlertModal` to accept multiple `SendAlert` items with prev/next navigation (design-system `ButtonIcon`, no “N of M” counter) and per-step acknowledge labels where needed. - Updated `Recipient` to open the modal when there are unacknowledged alerts, reset acknowledgement when the recipient changes, and block Review / auto-advance while alert checks are pending. - Removed the old `toAddressErrorAllowAcknowledge` path from address validation in favor of the dedicated alert pipeline; token-contract detection moved out of `validateHexAddress` into the alert hook. - Added/updated unit tests and locale strings for the new copy and navigation accessibility labels. ## **Changelog** CHANGELOG entry: Added Send flow warnings for first-time recipient interaction ## **Related issues** Fixes: https://consensyssoftware.atlassian.net/browse/CONF-1016 ## **Manual testing steps** ```gherkin Feature: Send flow first-time recipient Background: Given I am logged into MetaMask Mobile And I am on the Send flow recipient step for an EVM network Scenario: user sees first-time interaction alert for a new recipient Given I have an externally owned recipient address I have never sent to on this network And the recipient is not one of my accounts and is not shown as verified while trust data loads When user enters or selects that recipient and taps Review (or equivalent continue) Then a warning modal should appear about sending to this address for the first time And the modal should show the recipient address in the message When user acknowledges the alert (e.g. Continue / I understand as labeled) Then the flow should proceed toward confirmation as usual ``` ## **Screenshots/Recordings** https://github.com/user-attachments/assets/920408e5-a5b1-484c-9b1d-162bd170a666 ### **Before** ### **After** ## **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. ## **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 the Send recipient step gating logic and adds new asynchronous alert checks (token contract lookup and first-time interaction API), which can affect when users can proceed and may introduce edge cases around pending/acknowledgement state. > > **Overview** > Adds a new Send-flow alert pipeline (`useSendAlerts`) that surfaces **token contract** and **first-time recipient interaction** warnings before proceeding to confirmation, backed by a new `checkFirstTimeInteraction` util that calls `getAccountAddressRelationship`. > > Refactors `SendAlertModal` to accept a list of `SendAlert`s with prev/next navigation and per-alert acknowledge labels, and updates `Recipient` to block Review/auto-advance while alert checks are pending and to require acknowledging any unacknowledged alerts. > > Removes the old `toAddressErrorAllowAcknowledge`/token-contract detection from address validation (`validateHexAddress`/`useToAddressValidation`) and updates tests/locales accordingly. > > Reviewed by [Cursor Bugbot](https://cursor.com/bugbot) for commit 45a6f81e5a0b042c575fc8d5dad7cef21aafbeca. Bugbot is set up for automated code reviews on this repo. Configure [here](https://www.cursor.com/dashboard/bugbot). --- .../recipient-input/recipient-input.test.tsx | 1 - .../send/recipient/recipient.test.tsx | 87 ++++--- .../components/send/recipient/recipient.tsx | 33 ++- .../send-alert-modal.test.tsx | 81 +++---- .../send-alert-modal/send-alert-modal.tsx | 122 +++++++++- .../send-alert-modal.types.ts | 5 +- .../confirmations/hooks/send/alerts/types.ts | 8 + .../useFirstTimeInteractionSendAlert.test.tsx | 217 ++++++++++++++++++ .../useFirstTimeInteractionSendAlert.tsx | 102 ++++++++ .../hooks/send/alerts/useSendAlerts.test.ts | 174 ++++++++++++++ .../hooks/send/alerts/useSendAlerts.ts | 50 ++++ .../alerts/useTokenContractSendAlert.test.ts | 207 +++++++++++++++++ .../send/alerts/useTokenContractSendAlert.ts | 85 +++++++ .../hooks/send/useToAddressValidation.test.ts | 5 - .../hooks/send/useToAddressValidation.ts | 3 - .../utils/send-address-validations.test.ts | 17 +- .../utils/send-address-validations.ts | 28 --- app/util/transaction-controller/index.test.ts | 44 ++++ app/util/transaction-controller/index.ts | 18 ++ locales/languages/en.json | 6 +- 20 files changed, 1148 insertions(+), 145 deletions(-) create mode 100644 app/components/Views/confirmations/hooks/send/alerts/types.ts create mode 100644 app/components/Views/confirmations/hooks/send/alerts/useFirstTimeInteractionSendAlert.test.tsx create mode 100644 app/components/Views/confirmations/hooks/send/alerts/useFirstTimeInteractionSendAlert.tsx create mode 100644 app/components/Views/confirmations/hooks/send/alerts/useSendAlerts.test.ts create mode 100644 app/components/Views/confirmations/hooks/send/alerts/useSendAlerts.ts create mode 100644 app/components/Views/confirmations/hooks/send/alerts/useTokenContractSendAlert.test.ts create mode 100644 app/components/Views/confirmations/hooks/send/alerts/useTokenContractSendAlert.ts diff --git a/app/components/Views/confirmations/components/recipient-input/recipient-input.test.tsx b/app/components/Views/confirmations/components/recipient-input/recipient-input.test.tsx index 95faf9246de..d91a1ce1a23 100644 --- a/app/components/Views/confirmations/components/recipient-input/recipient-input.test.tsx +++ b/app/components/Views/confirmations/components/recipient-input/recipient-input.test.tsx @@ -80,7 +80,6 @@ describe('RecipientInput', () => { loading: false, resolvedAddress: undefined, toAddressError: undefined, - toAddressErrorAllowAcknowledge: false, toAddressValidated: undefined, toAddressWarning: undefined, }); diff --git a/app/components/Views/confirmations/components/send/recipient/recipient.test.tsx b/app/components/Views/confirmations/components/send/recipient/recipient.test.tsx index 7a8aae5cbca..75220003613 100644 --- a/app/components/Views/confirmations/components/send/recipient/recipient.test.tsx +++ b/app/components/Views/confirmations/components/send/recipient/recipient.test.tsx @@ -10,6 +10,7 @@ import { useContacts } from '../../../hooks/send/useContacts'; import { useToAddressValidation } from '../../../hooks/send/useToAddressValidation'; import { useRecipientSelectionMetrics } from '../../../hooks/send/metrics/useRecipientSelectionMetrics'; import { useSendActions } from '../../../hooks/send/useSendActions'; +import { useSendAlerts } from '../../../hooks/send/alerts/useSendAlerts'; import { useSendType } from '../../../hooks/send/useSendType'; import { RecipientType } from '../../UI/recipient'; import { Recipient } from './recipient'; @@ -65,6 +66,10 @@ jest.mock('../../../hooks/send/useToAddressValidation', () => ({ useToAddressValidation: jest.fn(), })); +jest.mock('../../../hooks/send/alerts/useSendAlerts', () => ({ + useSendAlerts: jest.fn(), +})); + jest.mock('../../../hooks/send/metrics/useRecipientSelectionMetrics', () => ({ useRecipientSelectionMetrics: jest.fn(), })); @@ -149,21 +154,20 @@ jest.mock('../send-alert-modal', () => ({ isOpen, onAcknowledge, onClose, - title, - errorMessage, + alerts, }: { isOpen: boolean; onAcknowledge: () => void; onClose: () => void; - title: string; - errorMessage: string; + alerts: { title: string; message: string }[]; }) => { const { View, Text, Pressable } = jest.requireActual('react-native'); - if (!isOpen) return null; + if (!isOpen || !alerts?.length) return null; + const first = alerts[0]; return ( - {title} - {errorMessage} + {first.title} + {first.message} Acknowledge @@ -199,6 +203,7 @@ const mockUseRecipientSelectionMetrics = jest.mocked( useRecipientSelectionMetrics, ); const mockUseSendActions = jest.mocked(useSendActions); +const mockUseSendAlerts = jest.mocked(useSendAlerts); const mockUseSendType = jest.mocked(useSendType); function createMockUseSendType( @@ -239,11 +244,17 @@ describe('Recipient', () => { loading: false, resolvedAddress: undefined, toAddressError: undefined, - toAddressErrorAllowAcknowledge: false, toAddressValidated: undefined, toAddressWarning: undefined, }); + mockUseSendAlerts.mockReturnValue({ + alerts: [], + hasUnacknowledgedAlerts: false, + acknowledgeAlerts: jest.fn(), + isAlertCheckPending: false, + }); + mockUseRecipientSelectionMetrics.mockReturnValue({ captureRecipientSelected: mockCaptureRecipientSelected, }); @@ -342,7 +353,6 @@ describe('Recipient', () => { loading: false, resolvedAddress: 'some_dummy_address', toAddressError: undefined, - toAddressErrorAllowAcknowledge: false, toAddressValidated: undefined, toAddressWarning: undefined, }); @@ -435,7 +445,6 @@ describe('Recipient', () => { loading: false, resolvedAddress: undefined, toAddressError: undefined, - toAddressErrorAllowAcknowledge: false, toAddressValidated: undefined, toAddressWarning: 'Warning', }); @@ -463,7 +472,6 @@ describe('Recipient', () => { loading: false, resolvedAddress: undefined, toAddressError: 'Error', - toAddressErrorAllowAcknowledge: false, toAddressValidated: undefined, toAddressWarning: 'Warning', }); @@ -501,7 +509,6 @@ describe('Recipient', () => { loading: true, resolvedAddress: undefined, toAddressError: undefined, - toAddressErrorAllowAcknowledge: false, toAddressValidated: undefined, toAddressWarning: 'Warning', }); @@ -569,7 +576,6 @@ describe('Recipient pastedRecipient effect gating (lines 96-101)', () => { loading: false, resolvedAddress: '0xresolved', toAddressError: undefined, - toAddressErrorAllowAcknowledge: false, toAddressValidated: '0xother', toAddressWarning: undefined, }); @@ -585,7 +591,6 @@ describe('Recipient pastedRecipient effect gating (lines 96-101)', () => { loading: false, resolvedAddress: '0xresolved', toAddressError: 'Invalid address', - toAddressErrorAllowAcknowledge: false, toAddressValidated: '0xvalid', toAddressWarning: undefined, }); @@ -601,7 +606,6 @@ describe('Recipient pastedRecipient effect gating (lines 96-101)', () => { loading: false, resolvedAddress: '0xresolved', toAddressError: undefined, - toAddressErrorAllowAcknowledge: false, toAddressValidated: '0xvalid', toAddressWarning: 'Warning', }); @@ -617,7 +621,6 @@ describe('Recipient pastedRecipient effect gating (lines 96-101)', () => { loading: true, resolvedAddress: '0xresolved', toAddressError: undefined, - toAddressErrorAllowAcknowledge: false, toAddressValidated: '0xvalid', toAddressWarning: undefined, }); @@ -634,7 +637,6 @@ describe('Recipient pastedRecipient effect gating (lines 96-101)', () => { loading: false, resolvedAddress: undefined, toAddressError: undefined, - toAddressErrorAllowAcknowledge: false, toAddressValidated: undefined, toAddressWarning: undefined, }); @@ -668,7 +670,6 @@ describe('Recipient pastedRecipient effect gating (lines 96-101)', () => { loading: false, resolvedAddress: undefined, toAddressError: undefined, - toAddressErrorAllowAcknowledge: false, toAddressValidated: undefined, toAddressWarning: undefined, }); @@ -706,7 +707,6 @@ describe('Recipient pastedRecipient effect gating (lines 96-101)', () => { loading: false, resolvedAddress: undefined, toAddressError: 'Error', - toAddressErrorAllowAcknowledge: false, toAddressValidated: undefined, toAddressWarning: undefined, }); @@ -744,7 +744,10 @@ describe('SendAlertModal integration', () => { let mockHandleSubmitPressLocal: jest.Mock; const setupTokenContractScenario = ( - overrides: Partial> = {}, + validationOverrides: Partial< + ReturnType + > = {}, + sendAlertsOverrides: Partial> = {}, ) => { mockHandleSubmitPressLocal = jest.fn(); mockUseSendActions.mockReturnValue({ @@ -771,11 +774,24 @@ describe('SendAlertModal integration', () => { mockUseToAddressValidation.mockReturnValue({ loading: false, resolvedAddress: undefined, - toAddressError: 'Token contract warning', - toAddressErrorAllowAcknowledge: true, + toAddressError: undefined, toAddressValidated: '0x1234567890123456789012345678901234567890', toAddressWarning: undefined, - ...overrides, + ...validationOverrides, + }); + + mockUseSendAlerts.mockReturnValue({ + alerts: [ + { + key: 'tokenContract', + title: 'Smart contract address', + message: 'You are sending to a smart contract address', + }, + ], + hasUnacknowledgedAlerts: true, + acknowledgeAlerts: jest.fn(), + isAlertCheckPending: false, + ...sendAlertsOverrides, }); mockUseRecipientSelectionMetrics.mockReturnValue({ @@ -793,7 +809,7 @@ describe('SendAlertModal integration', () => { jest.clearAllMocks(); }); - it('opens alert modal when review pressed and toAddressErrorAllowAcknowledge is true', () => { + it('opens alert modal when review pressed and has unacknowledged send alerts', () => { setupTokenContractScenario(); const { getByTestId } = renderWithProvider(); @@ -841,11 +857,16 @@ describe('SendAlertModal integration', () => { ); }); - it('does not show alert modal when toAddressErrorAllowAcknowledge is false', () => { - setupTokenContractScenario({ - toAddressError: 'Some error', - toAddressErrorAllowAcknowledge: false, - }); + it('does not show alert modal when there are no unacknowledged send alerts', () => { + setupTokenContractScenario( + { + toAddressError: 'Some error', + }, + { + alerts: [], + hasUnacknowledgedAlerts: false, + }, + ); const { getByTestId, queryByTestId } = renderWithProvider(); @@ -892,11 +913,17 @@ describe('SendAlertModal integration', () => { loading: false, resolvedAddress: undefined, toAddressError: undefined, - toAddressErrorAllowAcknowledge: false, toAddressValidated: '0xvalid', toAddressWarning: undefined, }); + mockUseSendAlerts.mockReturnValue({ + alerts: [], + hasUnacknowledgedAlerts: false, + acknowledgeAlerts: jest.fn(), + isAlertCheckPending: false, + }); + mockUseAccounts.mockReturnValue(mockAccounts); mockUseContacts.mockReturnValue(mockContacts); mockUseSendType.mockReturnValue({ diff --git a/app/components/Views/confirmations/components/send/recipient/recipient.tsx b/app/components/Views/confirmations/components/send/recipient/recipient.tsx index 3fce15c9076..178ff99b9ac 100644 --- a/app/components/Views/confirmations/components/send/recipient/recipient.tsx +++ b/app/components/Views/confirmations/components/send/recipient/recipient.tsx @@ -15,6 +15,7 @@ import Banner, { } from '../../../../../../component-library/components/Banners/Banner'; import { useSendContext } from '../../../context/send-context/send-context'; import { RecipientInputMethod } from '../../../context/send-context/send-metrics-context'; +import { useSendAlerts } from '../../../hooks/send/alerts/useSendAlerts'; import { useRecipientSelectionMetrics } from '../../../hooks/send/metrics/useRecipientSelectionMetrics'; import { useAccounts } from '../../../hooks/send/useAccounts'; import { useContacts } from '../../../hooks/send/useContacts'; @@ -41,15 +42,20 @@ export const Recipient = () => { const styles = styleSheet(); const { toAddressError, - toAddressErrorAllowAcknowledge, toAddressWarning, toAddressValidated, loading, resolvedAddress, } = useToAddressValidation(); - const hasBlockingError = - Boolean(toAddressError) && !toAddressErrorAllowAcknowledge; + const { + alerts, + hasUnacknowledgedAlerts, + acknowledgeAlerts, + isAlertCheckPending, + } = useSendAlerts(); + + const hasBlockingError = Boolean(toAddressError); // This hook needs to be called to update ERC721 NFTs in send flow // because that flow is triggered directly from the asset details page and user is redirected to the recipient page useRouteParams(); @@ -98,15 +104,16 @@ export const Recipient = () => { const handleAlertModalAcknowledge = useCallback(async () => { setIsAlertModalOpen(false); + acknowledgeAlerts(); await proceedWithSubmit(false); - }, [proceedWithSubmit]); + }, [acknowledgeAlerts, proceedWithSubmit]); const handleReview = useCallback( async (isPasted?: boolean) => { if (hasBlockingError || isSubmittingTransaction) { return; } - if (toAddressErrorAllowAcknowledge) { + if (hasUnacknowledgedAlerts) { setIsAlertModalOpen(true); return; } @@ -114,7 +121,7 @@ export const Recipient = () => { }, [ hasBlockingError, - toAddressErrorAllowAcknowledge, + hasUnacknowledgedAlerts, isSubmittingTransaction, proceedWithSubmit, ], @@ -126,7 +133,9 @@ export const Recipient = () => { pastedRecipient === toAddressValidated && !toAddressError && !toAddressWarning && - !loading + !loading && + !isAlertCheckPending && + !hasUnacknowledgedAlerts ) { handleReview(true); } @@ -137,6 +146,8 @@ export const Recipient = () => { toAddressValidated, toAddressWarning, loading, + isAlertCheckPending, + hasUnacknowledgedAlerts, ]); const onRecipientSelected = useCallback( @@ -240,7 +251,10 @@ export const Recipient = () => { twClassName="w-full" isDanger={!loading && hasBlockingError} disabled={ - hasBlockingError || isSubmittingTransaction || loading + hasBlockingError || + isSubmittingTransaction || + loading || + isAlertCheckPending } isLoading={isSubmittingTransaction || loading} > @@ -250,8 +264,7 @@ export const Recipient = () => { )} diff --git a/app/components/Views/confirmations/components/send/send-alert-modal/send-alert-modal.test.tsx b/app/components/Views/confirmations/components/send/send-alert-modal/send-alert-modal.test.tsx index 4327e5fbe17..e01f8ab64b0 100644 --- a/app/components/Views/confirmations/components/send/send-alert-modal/send-alert-modal.test.tsx +++ b/app/components/Views/confirmations/components/send/send-alert-modal/send-alert-modal.test.tsx @@ -2,6 +2,7 @@ import React from 'react'; import { fireEvent } from '@testing-library/react-native'; import renderWithProvider from '../../../../../../util/test/renderWithProvider'; +import type { SendAlert } from '../../../hooks/send/alerts/types'; import { SendAlertModal } from './send-alert-modal'; jest.mock('../../../../../../../locales/i18n', () => ({ @@ -9,6 +10,8 @@ jest.mock('../../../../../../../locales/i18n', () => ({ const mockStrings: Record = { 'send.cancel': 'Cancel', 'send.i_understand': 'I understand', + 'send.alert_navigation_previous': 'Previous alert', + 'send.alert_navigation_next': 'Next alert', }; return mockStrings[key] || key; }), @@ -70,11 +73,32 @@ jest.mock( }, ); +const singleAlert: SendAlert[] = [ + { + key: 'tokenContract', + title: 'Token Contract Address', + message: 'Sending to a token contract may result in lost tokens.', + }, +]; + +const twoAlerts: SendAlert[] = [ + { + key: 'tokenContract', + title: 'Smart contract address', + message: 'Token contract warning text.', + }, + { + key: 'firstTimeInteraction', + title: 'New address', + message: 'First time message', + acknowledgeButtonLabel: 'Continue', + }, +]; + describe('SendAlertModal', () => { const defaultProps = { isOpen: true, - title: 'Token Contract Address', - errorMessage: 'Sending to a token contract may result in lost tokens.', + alerts: singleAlert, onAcknowledge: jest.fn(), onClose: jest.fn(), }; @@ -91,6 +115,14 @@ describe('SendAlertModal', () => { expect(toJSON()).toBeNull(); }); + it('returns null when alerts is empty', () => { + const { toJSON } = renderWithProvider( + , + ); + + expect(toJSON()).toBeNull(); + }); + it('renders modal content when isOpen is true', () => { const { getByText } = renderWithProvider( , @@ -102,22 +134,6 @@ describe('SendAlertModal', () => { ).toBeOnTheScreen(); }); - it('displays the title text', () => { - const { getByText } = renderWithProvider( - , - ); - - expect(getByText('Custom Title')).toBeOnTheScreen(); - }); - - it('displays the error message text', () => { - const { getByText } = renderWithProvider( - , - ); - - expect(getByText('Custom error message')).toBeOnTheScreen(); - }); - it('calls onClose when cancel button is pressed', () => { const onClose = jest.fn(); const { getByTestId } = renderWithProvider( @@ -129,7 +145,7 @@ describe('SendAlertModal', () => { expect(onClose).toHaveBeenCalledTimes(1); }); - it('calls onAcknowledge when acknowledge button is pressed', () => { + it('calls onAcknowledge when acknowledge is pressed on last alert', () => { const onAcknowledge = jest.fn(); const { getByTestId } = renderWithProvider( , @@ -140,37 +156,22 @@ describe('SendAlertModal', () => { expect(onAcknowledge).toHaveBeenCalledTimes(1); }); - it('does not call onAcknowledge when cancel is pressed', () => { + it('advances to second alert when multiple alerts and first acknowledge', () => { const onAcknowledge = jest.fn(); - const onClose = jest.fn(); - const { getByTestId } = renderWithProvider( + const { getByText, getByTestId } = renderWithProvider( , ); - fireEvent.press(getByTestId('send-alert-modal-cancel-button')); - + expect(getByText('Smart contract address')).toBeOnTheScreen(); + fireEvent.press(getByTestId('send-alert-modal-acknowledge-button')); + expect(getByText('New address')).toBeOnTheScreen(); expect(onAcknowledge).not.toHaveBeenCalled(); - expect(onClose).toHaveBeenCalledTimes(1); - }); - - it('does not call onClose when acknowledge is pressed', () => { - const onAcknowledge = jest.fn(); - const onClose = jest.fn(); - const { getByTestId } = renderWithProvider( - , - ); fireEvent.press(getByTestId('send-alert-modal-acknowledge-button')); - - expect(onClose).not.toHaveBeenCalled(); expect(onAcknowledge).toHaveBeenCalledTimes(1); }); }); diff --git a/app/components/Views/confirmations/components/send/send-alert-modal/send-alert-modal.tsx b/app/components/Views/confirmations/components/send/send-alert-modal/send-alert-modal.tsx index 5fa80651a23..aa65df44b5a 100644 --- a/app/components/Views/confirmations/components/send/send-alert-modal/send-alert-modal.tsx +++ b/app/components/Views/confirmations/components/send/send-alert-modal/send-alert-modal.tsx @@ -1,8 +1,10 @@ -import React, { useRef } from 'react'; +import React, { useCallback, useEffect, useRef, useState } from 'react'; import { Box, BoxAlignItems, BoxFlexDirection, + BoxJustifyContent, + ButtonIcon, Icon, IconColor, IconName, @@ -20,38 +22,138 @@ import { ButtonSize, ButtonVariants, } from '../../../../../../component-library/components/Buttons/Button'; +import type { SendAlert } from '../../../hooks/send/alerts/types'; import { SendAlertModalProps } from './send-alert-modal.types'; +function PageNavigation({ + alerts, + selectedIndex, + onBack, + onForward, +}: { + alerts: SendAlert[]; + selectedIndex: number; + onBack: () => void; + onForward: () => void; +}) { + if (alerts.length <= 1) { + return null; + } + + return ( + + + {selectedIndex > 0 ? ( + + ) : ( + + )} + + + {selectedIndex < alerts.length - 1 ? ( + + ) : ( + + )} + + + ); +} + export const SendAlertModal = ({ isOpen, - title, - errorMessage, + alerts, onAcknowledge, onClose, }: SendAlertModalProps) => { const bottomSheetRef = useRef(null); + const [currentIndex, setCurrentIndex] = useState(0); + + const alertKeys = alerts.map((a) => a.key).join('|'); + + useEffect(() => { + setCurrentIndex(0); + }, [isOpen, alertKeys]); + + const safeIndex = Math.min(currentIndex, Math.max(alerts.length - 1, 0)); + const currentAlert = alerts[safeIndex]; + + const goToPrevious = useCallback(() => { + setCurrentIndex((prev) => Math.max(prev - 1, 0)); + }, []); + + const goToNext = useCallback(() => { + setCurrentIndex((prev) => Math.min(prev + 1, alerts.length - 1)); + }, [alerts.length]); + + const isOnLastAlert = safeIndex >= Math.max(alerts.length - 1, 0); + + const handleAcknowledgeStep = useCallback(() => { + if (isOnLastAlert) { + onAcknowledge(); + return; + } + goToNext(); + }, [goToNext, isOnLastAlert, onAcknowledge]); if (!isOpen) { return null; } + if (!currentAlert) { + return null; + } + + const acknowledgeLabel = + currentAlert.acknowledgeButtonLabel ?? strings('send.i_understand'); + return ( + - {title} - - {errorMessage} - + {currentAlert.title} + + {typeof currentAlert.message === 'string' ? ( + + {currentAlert.message} + + ) : ( + currentAlert.message + )} + void; onClose: () => void; } diff --git a/app/components/Views/confirmations/hooks/send/alerts/types.ts b/app/components/Views/confirmations/hooks/send/alerts/types.ts new file mode 100644 index 00000000000..a82e82b60f9 --- /dev/null +++ b/app/components/Views/confirmations/hooks/send/alerts/types.ts @@ -0,0 +1,8 @@ +import type { ReactNode } from 'react'; + +export interface SendAlert { + key: string; + title: string; + message: ReactNode; + acknowledgeButtonLabel?: string; +} diff --git a/app/components/Views/confirmations/hooks/send/alerts/useFirstTimeInteractionSendAlert.test.tsx b/app/components/Views/confirmations/hooks/send/alerts/useFirstTimeInteractionSendAlert.test.tsx new file mode 100644 index 00000000000..b8e97de0d16 --- /dev/null +++ b/app/components/Views/confirmations/hooks/send/alerts/useFirstTimeInteractionSendAlert.test.tsx @@ -0,0 +1,217 @@ +import { renderHook } from '@testing-library/react-hooks'; +import { useSelector } from 'react-redux'; + +import { checkFirstTimeInteraction } from '../../../../../../util/transaction-controller'; +import { useAsyncResult } from '../../../../../hooks/useAsyncResult'; +import { useSendContext } from '../../../context/send-context/send-context'; +import { TrustSignalDisplayState } from '../../../types/trustSignals'; +import { useAddressTrustSignal } from '../../useAddressTrustSignals'; +import { useFirstTimeInteractionSendAlert } from './useFirstTimeInteractionSendAlert'; + +jest.mock('react-redux', () => ({ + useSelector: jest.fn(), +})); + +jest.mock('../../../context/send-context/send-context', () => ({ + useSendContext: jest.fn(), +})); + +jest.mock('../../useAddressTrustSignals', () => ({ + useAddressTrustSignal: jest.fn(), +})); + +jest.mock('../../../../../hooks/useAsyncResult', () => ({ + useAsyncResult: jest.fn(), +})); + +jest.mock('../../../../../../util/transaction-controller', () => ({ + checkFirstTimeInteraction: jest.fn(), +})); + +jest.mock('../../../../../../../locales/i18n', () => ({ + strings: (key: string) => { + const map: Record = { + 'send.new_address_title': 'New address', + 'send.new_address_message': 'First time message', + 'send.continue': 'Continue', + }; + return map[key] || key; + }, +})); + +const mockUseSendContext = jest.mocked(useSendContext); +const mockUseSelector = jest.mocked(useSelector); +const mockUseAddressTrustSignal = jest.mocked(useAddressTrustSignal); +const mockUseAsyncResult = jest.mocked(useAsyncResult); + +describe('useFirstTimeInteractionSendAlert', () => { + const TO = '0xRecipientAddress'; + const FROM = '0xSenderAddress'; + const CHAIN_ID = '0x1'; + + beforeEach(() => { + jest.clearAllMocks(); + + mockUseSendContext.mockReturnValue({ + to: TO, + from: FROM, + chainId: CHAIN_ID, + } as unknown as ReturnType); + + mockUseSelector.mockReturnValue([]); + + mockUseAddressTrustSignal.mockReturnValue({ + state: TrustSignalDisplayState.Unknown, + label: null, + }); + + mockUseAsyncResult.mockReturnValue({ pending: false, value: true }); + }); + + it('returns alert when first-time interaction is detected', () => { + const { result } = renderHook(() => useFirstTimeInteractionSendAlert()); + + expect(result.current.alert).not.toBeNull(); + expect(result.current.alert?.key).toBe('firstTimeInteraction'); + expect(result.current.alert?.title).toBe('New address'); + expect(result.current.alert?.acknowledgeButtonLabel).toBe('Continue'); + expect(result.current.isPending).toBe(false); + }); + + it('returns null alert when to is missing', () => { + mockUseSendContext.mockReturnValue({ + to: undefined, + from: FROM, + chainId: CHAIN_ID, + } as unknown as ReturnType); + + const { result } = renderHook(() => useFirstTimeInteractionSendAlert()); + + expect(result.current.alert).toBeNull(); + expect(result.current.isPending).toBe(false); + }); + + it('returns null alert when from is missing', () => { + mockUseSendContext.mockReturnValue({ + to: TO, + from: undefined, + chainId: CHAIN_ID, + } as unknown as ReturnType); + + const { result } = renderHook(() => useFirstTimeInteractionSendAlert()); + + expect(result.current.alert).toBeNull(); + }); + + it('returns null alert when chainId is missing', () => { + mockUseSendContext.mockReturnValue({ + to: TO, + from: FROM, + chainId: undefined, + } as unknown as ReturnType); + + const { result } = renderHook(() => useFirstTimeInteractionSendAlert()); + + expect(result.current.alert).toBeNull(); + }); + + it('returns null alert when to is an internal account', () => { + mockUseSelector.mockReturnValue([{ address: TO.toLowerCase() }]); + + const { result } = renderHook(() => useFirstTimeInteractionSendAlert()); + + expect(result.current.alert).toBeNull(); + }); + + it('returns null alert when address is verified', () => { + mockUseAddressTrustSignal.mockReturnValue({ + state: TrustSignalDisplayState.Verified, + label: null, + }); + + const { result } = renderHook(() => useFirstTimeInteractionSendAlert()); + + expect(result.current.alert).toBeNull(); + }); + + it('returns isPending true when trust signal is loading', () => { + mockUseAddressTrustSignal.mockReturnValue({ + state: TrustSignalDisplayState.Loading, + label: null, + }); + + const { result } = renderHook(() => useFirstTimeInteractionSendAlert()); + + expect(result.current.alert).toBeNull(); + expect(result.current.isPending).toBe(true); + }); + + it('returns isPending true when async check is pending', () => { + mockUseAsyncResult.mockReturnValue({ pending: true }); + + const { result } = renderHook(() => useFirstTimeInteractionSendAlert()); + + expect(result.current.alert).toBeNull(); + expect(result.current.isPending).toBe(true); + }); + + it('returns null alert when isFirstTime is false', () => { + mockUseAsyncResult.mockReturnValue({ pending: false, value: false }); + + const { result } = renderHook(() => useFirstTimeInteractionSendAlert()); + + expect(result.current.alert).toBeNull(); + expect(result.current.isPending).toBe(false); + }); + + it('returns null alert when isFirstTime is undefined', () => { + mockUseAsyncResult.mockReturnValue({ pending: false, value: undefined }); + + const { result } = renderHook(() => useFirstTimeInteractionSendAlert()); + + expect(result.current.alert).toBeNull(); + }); + + it('passes correct arguments to checkFirstTimeInteraction via useAsyncResult', () => { + renderHook(() => useFirstTimeInteractionSendAlert()); + + expect(mockUseAsyncResult).toHaveBeenCalled(); + const asyncFn = mockUseAsyncResult.mock.calls[0][0]; + + asyncFn(); + + expect(checkFirstTimeInteraction).toHaveBeenCalledWith({ + from: FROM, + to: TO, + chainId: 1, + }); + }); + + it('skips the async call when shouldSkip is true', async () => { + mockUseSendContext.mockReturnValue({ + to: undefined, + from: FROM, + chainId: CHAIN_ID, + } as unknown as ReturnType); + + renderHook(() => useFirstTimeInteractionSendAlert()); + + const asyncFn = mockUseAsyncResult.mock.calls[0][0]; + + await expect(asyncFn()).resolves.toBeUndefined(); + expect(checkFirstTimeInteraction).not.toHaveBeenCalled(); + }); + + it('returns isPending false when shouldSkip is true even if async is pending', () => { + mockUseSendContext.mockReturnValue({ + to: undefined, + from: FROM, + chainId: CHAIN_ID, + } as unknown as ReturnType); + mockUseAsyncResult.mockReturnValue({ pending: true }); + + const { result } = renderHook(() => useFirstTimeInteractionSendAlert()); + + expect(result.current.isPending).toBe(false); + }); +}); diff --git a/app/components/Views/confirmations/hooks/send/alerts/useFirstTimeInteractionSendAlert.tsx b/app/components/Views/confirmations/hooks/send/alerts/useFirstTimeInteractionSendAlert.tsx new file mode 100644 index 00000000000..4f3a21f1828 --- /dev/null +++ b/app/components/Views/confirmations/hooks/send/alerts/useFirstTimeInteractionSendAlert.tsx @@ -0,0 +1,102 @@ +import { + Box, + BoxFlexDirection, + BoxFlexWrap, + FontWeight, + Text, + TextVariant, +} from '@metamask/design-system-react-native'; +import { InternalAccount } from '@metamask/keyring-internal-api'; +import { Hex, hexToNumber } from '@metamask/utils'; +import React, { useMemo } from 'react'; +import { useSelector } from 'react-redux'; + +import { strings } from '../../../../../../../locales/i18n'; +import { selectInternalAccounts } from '../../../../../../selectors/accountsController'; +import { checkFirstTimeInteraction } from '../../../../../../util/transaction-controller'; +import { useAsyncResult } from '../../../../../hooks/useAsyncResult'; +import { useSendContext } from '../../../context/send-context/send-context'; +import { TrustSignalDisplayState } from '../../../types/trustSignals'; +import { useAddressTrustSignal } from '../../useAddressTrustSignals'; +import type { SendAlert } from './types'; + +export function useFirstTimeInteractionSendAlert(): { + alert: SendAlert | null; + isPending: boolean; +} { + const { to, from, chainId } = useSendContext(); + const internalAccounts = useSelector( + selectInternalAccounts, + ) as InternalAccount[]; + + const trustSignalResult = useAddressTrustSignal(to ?? '', chainId ?? ''); + + const isInternalAccount = useMemo(() => { + if (!to) { + return false; + } + return internalAccounts.some( + (account) => account.address?.toLowerCase() === to.toLowerCase(), + ); + }, [internalAccounts, to]); + + const isVerifiedAddress = + trustSignalResult.state === TrustSignalDisplayState.Verified; + + const isTrustSignalLoading = + trustSignalResult.state === TrustSignalDisplayState.Loading; + + const shouldSkip = + !to || + !from || + !chainId || + isInternalAccount || + isVerifiedAddress || + isTrustSignalLoading; + + const { pending, value: isFirstTime } = useAsyncResult(async () => { + if (shouldSkip) { + return undefined; + } + const chainIdNum = hexToNumber(chainId as Hex); + return checkFirstTimeInteraction({ from, to, chainId: chainIdNum }); + }, [to, from, chainId, shouldSkip]); + + const isPending = isTrustSignalLoading || (!shouldSkip && pending); + + if (shouldSkip || pending || isFirstTime !== true) { + return { alert: null, isPending }; + } + + const message = ( + + + {strings('send.new_address_message')}{' '} + + + {to} + + + ); + + return { + alert: { + key: 'firstTimeInteraction', + title: strings('send.new_address_title'), + message, + acknowledgeButtonLabel: strings('send.continue'), + }, + isPending: false, + }; +} diff --git a/app/components/Views/confirmations/hooks/send/alerts/useSendAlerts.test.ts b/app/components/Views/confirmations/hooks/send/alerts/useSendAlerts.test.ts new file mode 100644 index 00000000000..261e34fd501 --- /dev/null +++ b/app/components/Views/confirmations/hooks/send/alerts/useSendAlerts.test.ts @@ -0,0 +1,174 @@ +import { renderHook, act } from '@testing-library/react-hooks'; + +import { useSendContext } from '../../../context/send-context/send-context'; +import { useFirstTimeInteractionSendAlert } from './useFirstTimeInteractionSendAlert'; +import { useTokenContractSendAlert } from './useTokenContractSendAlert'; +import { useSendAlerts } from './useSendAlerts'; + +jest.mock('../../../context/send-context/send-context', () => ({ + useSendContext: jest.fn(), +})); + +jest.mock('./useFirstTimeInteractionSendAlert', () => ({ + useFirstTimeInteractionSendAlert: jest.fn(), +})); + +jest.mock('./useTokenContractSendAlert', () => ({ + useTokenContractSendAlert: jest.fn(), +})); + +const mockUseSendContext = jest.mocked(useSendContext); +const mockUseFirstTimeInteraction = jest.mocked( + useFirstTimeInteractionSendAlert, +); +const mockUseTokenContract = jest.mocked(useTokenContractSendAlert); + +const TOKEN_ALERT = { + key: 'tokenContract', + title: 'Smart contract address', + message: 'Token contract warning', +}; + +const FIRST_TIME_ALERT = { + key: 'firstTimeInteraction', + title: 'New address', + message: 'First time message', + acknowledgeButtonLabel: 'Continue', +}; + +describe('useSendAlerts', () => { + beforeEach(() => { + jest.clearAllMocks(); + + mockUseSendContext.mockReturnValue({ + to: '0xRecipient', + } as unknown as ReturnType); + + mockUseTokenContract.mockReturnValue({ + alert: null, + isPending: false, + }); + + mockUseFirstTimeInteraction.mockReturnValue({ + alert: null, + isPending: false, + }); + }); + + it('returns empty alerts when no sub-hooks produce alerts', () => { + const { result } = renderHook(() => useSendAlerts()); + + expect(result.current.alerts).toEqual([]); + expect(result.current.hasUnacknowledgedAlerts).toBe(false); + expect(result.current.isAlertCheckPending).toBe(false); + }); + + it('collects token contract alert', () => { + mockUseTokenContract.mockReturnValue({ + alert: TOKEN_ALERT, + isPending: false, + }); + + const { result } = renderHook(() => useSendAlerts()); + + expect(result.current.alerts).toEqual([TOKEN_ALERT]); + expect(result.current.hasUnacknowledgedAlerts).toBe(true); + }); + + it('collects first-time interaction alert', () => { + mockUseFirstTimeInteraction.mockReturnValue({ + alert: FIRST_TIME_ALERT, + isPending: false, + }); + + const { result } = renderHook(() => useSendAlerts()); + + expect(result.current.alerts).toEqual([FIRST_TIME_ALERT]); + expect(result.current.hasUnacknowledgedAlerts).toBe(true); + }); + + it('collects both alerts in order: token contract first', () => { + mockUseTokenContract.mockReturnValue({ + alert: TOKEN_ALERT, + isPending: false, + }); + mockUseFirstTimeInteraction.mockReturnValue({ + alert: FIRST_TIME_ALERT, + isPending: false, + }); + + const { result } = renderHook(() => useSendAlerts()); + + expect(result.current.alerts).toEqual([TOKEN_ALERT, FIRST_TIME_ALERT]); + expect(result.current.hasUnacknowledgedAlerts).toBe(true); + }); + + it('reports isAlertCheckPending when token contract check is pending', () => { + mockUseTokenContract.mockReturnValue({ + alert: null, + isPending: true, + }); + + const { result } = renderHook(() => useSendAlerts()); + + expect(result.current.isAlertCheckPending).toBe(true); + }); + + it('reports isAlertCheckPending when first-time check is pending', () => { + mockUseFirstTimeInteraction.mockReturnValue({ + alert: null, + isPending: true, + }); + + const { result } = renderHook(() => useSendAlerts()); + + expect(result.current.isAlertCheckPending).toBe(true); + }); + + it('acknowledgeAlerts sets hasUnacknowledgedAlerts to false', () => { + mockUseTokenContract.mockReturnValue({ + alert: TOKEN_ALERT, + isPending: false, + }); + + const { result } = renderHook(() => useSendAlerts()); + + expect(result.current.hasUnacknowledgedAlerts).toBe(true); + + act(() => { + result.current.acknowledgeAlerts(); + }); + + expect(result.current.hasUnacknowledgedAlerts).toBe(false); + }); + + it('resets acknowledged state when to changes', () => { + mockUseTokenContract.mockReturnValue({ + alert: TOKEN_ALERT, + isPending: false, + }); + + const { result, rerender } = renderHook(() => useSendAlerts()); + + act(() => { + result.current.acknowledgeAlerts(); + }); + + expect(result.current.hasUnacknowledgedAlerts).toBe(false); + + mockUseSendContext.mockReturnValue({ + to: '0xNewRecipient', + } as unknown as ReturnType); + + rerender(); + + expect(result.current.hasUnacknowledgedAlerts).toBe(true); + }); + + it('returns hasUnacknowledgedAlerts false when alerts are empty even without acknowledging', () => { + const { result } = renderHook(() => useSendAlerts()); + + expect(result.current.alerts).toEqual([]); + expect(result.current.hasUnacknowledgedAlerts).toBe(false); + }); +}); diff --git a/app/components/Views/confirmations/hooks/send/alerts/useSendAlerts.ts b/app/components/Views/confirmations/hooks/send/alerts/useSendAlerts.ts new file mode 100644 index 00000000000..7eca5ff4de2 --- /dev/null +++ b/app/components/Views/confirmations/hooks/send/alerts/useSendAlerts.ts @@ -0,0 +1,50 @@ +import { useCallback, useEffect, useMemo, useState } from 'react'; + +import { useSendContext } from '../../../context/send-context/send-context'; +import { useFirstTimeInteractionSendAlert } from './useFirstTimeInteractionSendAlert'; +import { useTokenContractSendAlert } from './useTokenContractSendAlert'; +import type { SendAlert } from './types'; + +export function useSendAlerts(): { + alerts: SendAlert[]; + hasUnacknowledgedAlerts: boolean; + acknowledgeAlerts: () => void; + isAlertCheckPending: boolean; +} { + const { to } = useSendContext(); + const { alert: tokenContractAlert, isPending: tokenContractPending } = + useTokenContractSendAlert(); + const { alert: firstTimeAlert, isPending: firstTimePending } = + useFirstTimeInteractionSendAlert(); + const [acknowledged, setAcknowledged] = useState(false); + + const isAlertCheckPending = tokenContractPending || firstTimePending; + + const alerts = useMemo(() => { + const result: SendAlert[] = []; + if (tokenContractAlert) { + result.push(tokenContractAlert); + } + if (firstTimeAlert) { + result.push(firstTimeAlert); + } + return result; + }, [tokenContractAlert, firstTimeAlert]); + + useEffect(() => { + setAcknowledged(false); + }, [to]); + + const acknowledgeAlerts = useCallback(() => { + setAcknowledged(true); + }, []); + + const hasUnacknowledgedAlerts = alerts.length > 0 && !acknowledged; + + return { + alerts, + hasUnacknowledgedAlerts, + acknowledgeAlerts, + isAlertCheckPending, + }; +} diff --git a/app/components/Views/confirmations/hooks/send/alerts/useTokenContractSendAlert.test.ts b/app/components/Views/confirmations/hooks/send/alerts/useTokenContractSendAlert.test.ts new file mode 100644 index 00000000000..89f494a76d8 --- /dev/null +++ b/app/components/Views/confirmations/hooks/send/alerts/useTokenContractSendAlert.test.ts @@ -0,0 +1,207 @@ +import { renderHook, act } from '@testing-library/react-hooks'; + +import { + memoizedGetTokenStandardAndDetails, + type TokenDetails, +} from '../../../utils/token'; +import { useSendContext } from '../../../context/send-context/send-context'; +import { useSendType } from '../useSendType'; +import { useTokenContractSendAlert } from './useTokenContractSendAlert'; + +type TokenResult = TokenDetails | Record; + +jest.mock('../../../context/send-context/send-context', () => ({ + useSendContext: jest.fn(), +})); + +jest.mock('../useSendType', () => ({ + useSendType: jest.fn(), +})); + +jest.mock('../../../utils/token', () => ({ + memoizedGetTokenStandardAndDetails: jest.fn(), +})); + +jest.mock('../../../../../../core/Engine', () => ({ + context: { + NetworkController: { + findNetworkClientIdByChainId: jest.fn().mockReturnValue('mainnet'), + }, + }, +})); + +jest.mock('../../../../../../util/address', () => ({ + isValidHexAddress: jest.fn((addr: string) => addr.startsWith('0x')), + toChecksumAddress: jest.fn((addr: string) => addr), +})); + +jest.mock('../../../../../../../locales/i18n', () => ({ + strings: (key: string) => { + const map: Record = { + 'send.smart_contract_address': 'Smart contract address', + 'send.smart_contract_address_warning': 'Token contract warning', + }; + return map[key] || key; + }, +})); + +const mockUseSendContext = jest.mocked(useSendContext); +const mockUseSendType = jest.mocked(useSendType); +const mockGetTokenDetails = jest.mocked(memoizedGetTokenStandardAndDetails); + +describe('useTokenContractSendAlert', () => { + const TOKEN_CONTRACT = '0x935E73EDb9fF52E23BaC7F7e043A1ecD06d05477'; + + beforeEach(() => { + jest.clearAllMocks(); + + mockUseSendContext.mockReturnValue({ + to: TOKEN_CONTRACT, + chainId: '0x1', + asset: { address: '0xDifferentAddress' }, + } as unknown as ReturnType); + + mockUseSendType.mockReturnValue({ + isEvmSendType: true, + } as unknown as ReturnType); + + mockGetTokenDetails.mockResolvedValue({}); + }); + + it('returns null alert when to is missing', () => { + mockUseSendContext.mockReturnValue({ + to: undefined, + chainId: '0x1', + asset: {}, + } as unknown as ReturnType); + + const { result } = renderHook(() => useTokenContractSendAlert()); + + expect(result.current.alert).toBeNull(); + expect(result.current.isPending).toBe(false); + }); + + it('returns null alert when chainId is missing', () => { + mockUseSendContext.mockReturnValue({ + to: TOKEN_CONTRACT, + chainId: undefined, + asset: {}, + } as unknown as ReturnType); + + const { result } = renderHook(() => useTokenContractSendAlert()); + + expect(result.current.alert).toBeNull(); + }); + + it('returns null alert when not an EVM send type', () => { + mockUseSendType.mockReturnValue({ + isEvmSendType: false, + } as unknown as ReturnType); + + const { result } = renderHook(() => useTokenContractSendAlert()); + + expect(result.current.alert).toBeNull(); + }); + + it('returns null alert when to address equals asset address', () => { + mockUseSendContext.mockReturnValue({ + to: TOKEN_CONTRACT, + chainId: '0x1', + asset: { address: TOKEN_CONTRACT }, + } as unknown as ReturnType); + + const { result } = renderHook(() => useTokenContractSendAlert()); + + expect(result.current.alert).toBeNull(); + }); + + it('returns alert when address is a token contract', async () => { + mockGetTokenDetails.mockResolvedValue({ + standard: 'ERC20', + } as Awaited>); + + const { result, waitForNextUpdate } = renderHook(() => + useTokenContractSendAlert(), + ); + + await waitForNextUpdate(); + + expect(result.current.alert).not.toBeNull(); + expect(result.current.alert?.key).toBe('tokenContract'); + expect(result.current.alert?.title).toBe('Smart contract address'); + expect(result.current.alert?.message).toBe('Token contract warning'); + expect(result.current.isPending).toBe(false); + }); + + it('returns null alert when address is not a token contract', async () => { + mockGetTokenDetails.mockResolvedValue({}); + + const { result, waitForNextUpdate } = renderHook(() => + useTokenContractSendAlert(), + ); + + await waitForNextUpdate(); + + expect(result.current.alert).toBeNull(); + expect(result.current.isPending).toBe(false); + }); + + it('returns null alert when token details lookup throws', async () => { + mockGetTokenDetails.mockRejectedValue(new Error('lookup failed')); + + const { result, waitForNextUpdate } = renderHook(() => + useTokenContractSendAlert(), + ); + + await waitForNextUpdate(); + + expect(result.current.alert).toBeNull(); + expect(result.current.isPending).toBe(false); + }); + + it('reports isPending while the token check is in progress', async () => { + let resolvePromise: (v: TokenResult) => void = () => undefined; + mockGetTokenDetails.mockReturnValue( + new Promise((resolve) => { + resolvePromise = resolve; + }), + ); + + const { result } = renderHook(() => useTokenContractSendAlert()); + + expect(result.current.isPending).toBe(true); + + await act(async () => { + resolvePromise({}); + }); + }); + + it('cancels in-flight check when to changes', async () => { + let resolveFirst: (v: TokenResult) => void = () => undefined; + mockGetTokenDetails.mockReturnValueOnce( + new Promise((resolve) => { + resolveFirst = resolve; + }), + ); + + const { result, rerender } = renderHook(() => useTokenContractSendAlert()); + + expect(result.current.isPending).toBe(true); + + mockUseSendContext.mockReturnValue({ + to: '0xNewAddress', + chainId: '0x1', + asset: { address: '0xDifferentAddress' }, + } as unknown as ReturnType); + + mockGetTokenDetails.mockResolvedValueOnce({}); + + rerender(); + + await act(async () => { + resolveFirst({ standard: 'ERC20' } as TokenResult); + }); + + expect(result.current.alert).toBeNull(); + }); +}); diff --git a/app/components/Views/confirmations/hooks/send/alerts/useTokenContractSendAlert.ts b/app/components/Views/confirmations/hooks/send/alerts/useTokenContractSendAlert.ts new file mode 100644 index 00000000000..6e45d92fbc4 --- /dev/null +++ b/app/components/Views/confirmations/hooks/send/alerts/useTokenContractSendAlert.ts @@ -0,0 +1,85 @@ +import { Hex } from '@metamask/utils'; +import { useEffect, useState } from 'react'; + +import { strings } from '../../../../../../../locales/i18n'; +import Engine from '../../../../../../core/Engine'; +import { + isValidHexAddress, + toChecksumAddress, +} from '../../../../../../util/address'; +import { memoizedGetTokenStandardAndDetails } from '../../../utils/token'; +import { useSendContext } from '../../../context/send-context/send-context'; +import { useSendType } from '../useSendType'; +import type { SendAlert } from './types'; + +export function useTokenContractSendAlert(): { + alert: SendAlert | null; + isPending: boolean; +} { + const { to, chainId, asset } = useSendContext(); + const { isEvmSendType } = useSendType(); + const [isTokenContract, setIsTokenContract] = useState(false); + const [checkComplete, setCheckComplete] = useState(true); + + useEffect(() => { + let cancelled = false; + setIsTokenContract(false); + + if (!to || !chainId || !isEvmSendType || !isValidHexAddress(to)) { + setCheckComplete(true); + return undefined; + } + + if (to?.toLowerCase() === asset?.address?.toLowerCase()) { + setCheckComplete(true); + return undefined; + } + + setCheckComplete(false); + + const checksummedAddress = toChecksumAddress(to); + const { NetworkController } = Engine.context; + const networkClientId = NetworkController.findNetworkClientIdByChainId( + chainId as Hex, + ); + + memoizedGetTokenStandardAndDetails({ + tokenAddress: checksummedAddress, + tokenId: undefined, + userAddress: undefined, + networkClientId, + }) + .then((token) => { + if (!cancelled && token?.standard) { + setIsTokenContract(true); + } + }) + .catch(() => { + // Not a token address + }) + .finally(() => { + if (!cancelled) { + setCheckComplete(true); + } + }); + + return () => { + cancelled = true; + }; + }, [to, chainId, isEvmSendType, asset?.address]); + + const isPending = !checkComplete; + + if (!isTokenContract) { + return { alert: null, isPending }; + } + + return { + alert: { + key: 'tokenContract', + title: strings('send.smart_contract_address'), + message: strings('send.smart_contract_address_warning'), + }, + isPending: false, + }; +} diff --git a/app/components/Views/confirmations/hooks/send/useToAddressValidation.test.ts b/app/components/Views/confirmations/hooks/send/useToAddressValidation.test.ts index 560cbcfeb55..342f69961db 100644 --- a/app/components/Views/confirmations/hooks/send/useToAddressValidation.test.ts +++ b/app/components/Views/confirmations/hooks/send/useToAddressValidation.test.ts @@ -46,7 +46,6 @@ describe('useToAddressValidation', () => { loading: false, resolvedAddress: undefined, toAddressError: undefined, - toAddressErrorAllowAcknowledge: false, toAddressValidated: undefined, toAddressWarning: undefined, }); @@ -64,7 +63,6 @@ describe('useToAddressValidation', () => { loading: false, resolvedAddress: undefined, toAddressError: undefined, - toAddressErrorAllowAcknowledge: false, toAddressValidated: undefined, toAddressWarning: undefined, }); @@ -92,7 +90,6 @@ describe('useToAddressValidation', () => { loading: false, resolvedAddress: undefined, toAddressError: 'Invalid address', - toAddressErrorAllowAcknowledge: false, toAddressValidated: '0x123', toAddressWarning: undefined, }); @@ -114,7 +111,6 @@ describe('useToAddressValidation', () => { loading: false, resolvedAddress: undefined, toAddressError: 'Invalid address', - toAddressErrorAllowAcknowledge: false, toAddressValidated: 'dummy', toAddressWarning: undefined, }); @@ -143,7 +139,6 @@ describe('useToAddressValidation', () => { loading: false, resolvedAddress: undefined, toAddressError: 'Invalid address', - toAddressErrorAllowAcknowledge: false, toAddressValidated: 'dummy', toAddressWarning: undefined, }); diff --git a/app/components/Views/confirmations/hooks/send/useToAddressValidation.ts b/app/components/Views/confirmations/hooks/send/useToAddressValidation.ts index bdd9eca301d..40b4a22ee91 100644 --- a/app/components/Views/confirmations/hooks/send/useToAddressValidation.ts +++ b/app/components/Views/confirmations/hooks/send/useToAddressValidation.ts @@ -20,7 +20,6 @@ interface ValidationResult { error?: string; warning?: string; resolvedAddress?: string; - allowAcknowledge?: boolean; } export const useToAddressValidation = () => { @@ -113,14 +112,12 @@ export const useToAddressValidation = () => { error, warning: toAddressWarning, resolvedAddress, - allowAcknowledge, } = result ?? {}; return { loading, resolvedAddress, toAddressError: error, - toAddressErrorAllowAcknowledge: allowAcknowledge === true, toAddressValidated, toAddressWarning, }; diff --git a/app/components/Views/confirmations/utils/send-address-validations.test.ts b/app/components/Views/confirmations/utils/send-address-validations.test.ts index 48d3c05ae5d..99604d29ffe 100644 --- a/app/components/Views/confirmations/utils/send-address-validations.test.ts +++ b/app/components/Views/confirmations/utils/send-address-validations.test.ts @@ -7,8 +7,6 @@ import { validateSolanaAddress, validateTronAddress, } from './send-address-validations'; -import { memoizedGetTokenStandardAndDetails, TokenDetailsERC20 } from './token'; - jest.mock('./token', () => ({ memoizedGetTokenStandardAndDetails: jest.fn().mockResolvedValue(undefined), })); @@ -21,10 +19,6 @@ jest.mock('../../../../core/Engine', () => ({ }, })); -const mockMemoizedGetTokenStandardAndDetails = jest.mocked( - memoizedGetTokenStandardAndDetails, -); - describe('validateHexAddress', () => { it('returns error if address is burn address', async () => { expect( @@ -70,20 +64,13 @@ describe('validateHexAddress', () => { ).toStrictEqual({}); }); - it('returns warning if address is contract address', async () => { - mockMemoizedGetTokenStandardAndDetails.mockResolvedValue({ - standard: 'ERC20', - } as unknown as TokenDetailsERC20); + it('does not flag token contract addresses (handled in send flow alerts)', async () => { expect( await validateHexAddress( '0x935E73EDb9fF52E23BaC7F7e043A1ecD06d05477', '0x1', ), - ).toStrictEqual({ - allowAcknowledge: true, - error: - 'This address is a token contract address. If you send tokens to this address, you will lose them.', - }); + ).toStrictEqual({}); }); }); diff --git a/app/components/Views/confirmations/utils/send-address-validations.ts b/app/components/Views/confirmations/utils/send-address-validations.ts index d3cb04483c8..e9a4dff7645 100644 --- a/app/components/Views/confirmations/utils/send-address-validations.ts +++ b/app/components/Views/confirmations/utils/send-address-validations.ts @@ -2,14 +2,11 @@ import { Hex } from '@metamask/utils'; import { isAddress as isSolanaAddress } from '@solana/addresses'; import { strings } from '../../../../../locales/i18n'; -import Engine from '../../../../core/Engine'; -import { toChecksumAddress } from '../../../../util/address'; import { collectConfusables, getConfusablesExplanations, hasZeroWidthPoints, } from '../../../../util/confusables'; -import { memoizedGetTokenStandardAndDetails } from './token'; import { isBtcMainnetAddress, isTronAddress, @@ -38,7 +35,6 @@ export const validateHexAddress = async ( ): Promise<{ error?: string; warning?: string; - allowAcknowledge?: boolean; }> => { if (LOWER_CASED_BURN_ADDRESSES.includes(toAddress?.toLowerCase())) { return { @@ -52,30 +48,6 @@ export const validateHexAddress = async ( }; } - const checksummedAddress = toChecksumAddress(toAddress); - if (chainId) { - const { NetworkController } = Engine.context; - - try { - const networkClientId = NetworkController.findNetworkClientIdByChainId( - chainId as Hex, - ); - const token = await memoizedGetTokenStandardAndDetails({ - tokenAddress: checksummedAddress, - tokenId: undefined, - userAddress: undefined, - networkClientId, - }); - if (token?.standard) { - return { - error: strings('send.token_contract_warning'), - allowAcknowledge: true, - }; - } - } catch { - // Not a token address - } - } return {}; }; diff --git a/app/util/transaction-controller/index.test.ts b/app/util/transaction-controller/index.test.ts index 3fa73204871..64827debf4f 100644 --- a/app/util/transaction-controller/index.test.ts +++ b/app/util/transaction-controller/index.test.ts @@ -4,6 +4,7 @@ import { type TransactionMeta, TransactionEnvelopeType, IsAtomicBatchSupportedRequest, + getAccountAddressRelationship, } from '@metamask/transaction-controller'; import { cloneDeep, omit } from 'lodash'; //eslint-disable-next-line import-x/no-namespace @@ -27,9 +28,19 @@ const { estimateGasFee, getPreviousGasFromController, getChainIdFromNetworkClientId, + checkFirstTimeInteraction, ...proxyMethods } = TransactionControllerUtils; +jest.mock('@metamask/transaction-controller', () => ({ + ...jest.requireActual('@metamask/transaction-controller'), + getAccountAddressRelationship: jest.fn(), +})); + +const mockGetAccountAddressRelationship = jest.mocked( + getAccountAddressRelationship, +); + jest.mock('../../store', () => ({ store: { getState: jest.fn(() => ({ @@ -868,4 +879,37 @@ describe('Transaction Controller Util', () => { expect(result).toBe(mockResult); }); }); + + describe('checkFirstTimeInteraction', () => { + const request = { from: '0xabc', to: '0xdef', chainId: 1 }; + + it('returns true when count is 0 (first time)', async () => { + mockGetAccountAddressRelationship.mockResolvedValueOnce({ count: 0 }); + const result = await checkFirstTimeInteraction(request); + expect(result).toBe(true); + expect(mockGetAccountAddressRelationship).toHaveBeenCalledWith(request); + }); + + it('returns false when count is greater than 0', async () => { + mockGetAccountAddressRelationship.mockResolvedValueOnce({ count: 5 }); + const result = await checkFirstTimeInteraction(request); + expect(result).toBe(false); + }); + + it('returns undefined when count is undefined', async () => { + mockGetAccountAddressRelationship.mockResolvedValueOnce({ + count: undefined, + }); + const result = await checkFirstTimeInteraction(request); + expect(result).toBeUndefined(); + }); + + it('returns undefined when API call throws', async () => { + mockGetAccountAddressRelationship.mockRejectedValueOnce( + new Error('network error'), + ); + const result = await checkFirstTimeInteraction(request); + expect(result).toBeUndefined(); + }); + }); }); diff --git a/app/util/transaction-controller/index.ts b/app/util/transaction-controller/index.ts index 50a7b31f042..247c46bed62 100644 --- a/app/util/transaction-controller/index.ts +++ b/app/util/transaction-controller/index.ts @@ -6,6 +6,7 @@ import { TransactionController as BaseTransactionController, IsAtomicBatchSupportedRequest, IsAtomicBatchSupportedResult, + getAccountAddressRelationship, Result, } from '@metamask/transaction-controller'; import { NetworkClientId } from '@metamask/network-controller'; @@ -330,6 +331,23 @@ export async function isAtomicBatchSupported( return TransactionController?.isAtomicBatchSupported(request); } +/** + * Returns whether the sender has no prior on-chain interaction with `to` on `chainId`, + * or `undefined` when the relationship cannot be determined (API error or unknown count). + */ +export async function checkFirstTimeInteraction(request: { + from: string; + to: string; + chainId: number; +}): Promise { + try { + const result = await getAccountAddressRelationship(request); + return result.count === undefined ? undefined : result.count === 0; + } catch { + return undefined; + } +} + function sanitizeTransactionParamsGasValues( transactionId: string, requestedTransactionParamsToUpdate: Partial, diff --git a/locales/languages/en.json b/locales/languages/en.json index aeb7ba9d85e..a222b35ae3b 100644 --- a/locales/languages/en.json +++ b/locales/languages/en.json @@ -709,7 +709,11 @@ "smart_contract_address": "Smart contract address", "smart_contract_address_warning": "The recipient address may not support direct token transfers, which could result in fund loss. Only continue if you're certain this contract can receive your transfer.", "i_understand": "I understand", - "cancel": "Cancel" + "cancel": "Cancel", + "new_address_title": "New address", + "new_address_message": "You're sending to this address for the first time. Make sure that it's correct before you continue:", + "alert_navigation_previous": "Previous alert", + "alert_navigation_next": "Next alert" }, "unified_ramp": { "networks_filter_bar": { From d55e006b0522621e15421b9545f9832b9b687521 Mon Sep 17 00:00:00 2001 From: sahar-fehri Date: Wed, 6 May 2026 11:27:43 +0200 Subject: [PATCH 4/9] fix: update font label in token details (#29710) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ## **Description** Update font for token details label. ## **Changelog** CHANGELOG entry: Update font for token details label ## **Related issues** Fixes: null ## **Manual testing steps** ```gherkin Feature: my feature name Scenario: user [verb for user action] Given [describe expected initial app state] When user [verb for user action] Then [describe expected outcome] ``` ## **Screenshots/Recordings** ### **Before** ### **After** ## **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) - [ ] I've tested on Android - Ideally on a mid-range device; emulator is acceptable - [ ] 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 - [ ] 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** > Low risk UI-only typography change that does not affect navigation, data, or analytics behavior. > > **Overview** > Updates the `SecurityTrustEntryCard` result label styling by switching from `TextVariant.HeadingMd` with a hard-coded `600` weight to `TextVariant.BodyMd` with `FontWeight.Medium`, aligning the label’s typography with the design system. > > Reviewed by [Cursor Bugbot](https://cursor.com/bugbot) for commit 827c6b1cd3672de446f7202650e3d754290d8c70. Bugbot is set up for automated code reviews on this repo. Configure [here](https://www.cursor.com/dashboard/bugbot). --- .../SecurityTrustEntryCard/SecurityTrustEntryCard.tsx | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/app/components/UI/SecurityTrust/components/SecurityTrustEntryCard/SecurityTrustEntryCard.tsx b/app/components/UI/SecurityTrust/components/SecurityTrustEntryCard/SecurityTrustEntryCard.tsx index f3aa0ed469c..d029711cd68 100644 --- a/app/components/UI/SecurityTrust/components/SecurityTrustEntryCard/SecurityTrustEntryCard.tsx +++ b/app/components/UI/SecurityTrust/components/SecurityTrustEntryCard/SecurityTrustEntryCard.tsx @@ -124,9 +124,9 @@ const SecurityTrustEntryCard: React.FC = ({ )} {config.label} From 5011f8494890ea977177e39b44bc32cfa5765422 Mon Sep 17 00:00:00 2001 From: Jyoti Puri Date: Wed, 6 May 2026 15:21:06 +0530 Subject: [PATCH 5/9] chore: update @metamask/transaction-pay-controller to version 21.0.0 (#29774) ## **Description** Update package @metamask/transaction-pay-controller. ## **Changelog** CHANGELOG entry: ## **Related issues** Fixes: https://consensyssoftware.atlassian.net/browse/CONF-1241 ## **Manual testing steps** ```gherkin Feature: my feature name Scenario: user [verb for user action] Given [describe expected initial app state] When user [verb for user action] Then [describe expected outcome] ``` ## **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) - [ ] I've tested on Android - Ideally on a mid-range device; emulator is acceptable - [ ] 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 - [ ] 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** > Upgrades the `@metamask/transaction-pay-controller` and related controller dependencies, which could subtly affect transaction/payment flows even though this PR mostly updates dependency metadata and test fixtures. > > **Overview** > Updates `@metamask/transaction-pay-controller` from `^20.0.0` to `^21.0.0`, pulling in newer versions of several MetaMask controller packages via `yarn.lock`. > > Adjusts test fixtures to match the new dependency behavior: `initial-background-state.json` now seeds `AssetsController.assetsInfo` with `mUSD` token metadata on multiple chains, and E2E RPC mocking adds a default mock for `https://testnet-rpc.monad.xyz/`. > > Reviewed by [Cursor Bugbot](https://cursor.com/bugbot) for commit c20bef91f96992b6ba32635ccf23b86a2e41bd94. Bugbot is set up for automated code reviews on this repo. Configure [here](https://www.cursor.com/dashboard/bugbot). --- app/util/test/initial-background-state.json | 21 +- package.json | 2 +- .../mock-responses/defaults/rpc-endpoints.ts | 9 + yarn.lock | 189 ++++++------------ 4 files changed, 90 insertions(+), 131 deletions(-) diff --git a/app/util/test/initial-background-state.json b/app/util/test/initial-background-state.json index b6c21b31582..72358bb8306 100644 --- a/app/util/test/initial-background-state.json +++ b/app/util/test/initial-background-state.json @@ -421,7 +421,26 @@ "AssetsController": { "assetPreferences": {}, "assetsBalance": {}, - "assetsInfo": {}, + "assetsInfo": { + "eip155:1/erc20:0xaca92e438df0b2401ff60da7e4337b687a2435da": { + "decimals": 6, + "name": "MetaMask USD", + "symbol": "mUSD", + "type": "erc20" + }, + "eip155:143/erc20:0xaca92e438df0b2401ff60da7e4337b687a2435da": { + "decimals": 6, + "name": "MetaMask USD", + "symbol": "mUSD", + "type": "erc20" + }, + "eip155:59144/erc20:0xaca92e438df0b2401ff60da7e4337b687a2435da": { + "decimals": 6, + "name": "MetaMask USD", + "symbol": "mUSD", + "type": "erc20" + } + }, "assetsPrice": {}, "customAssets": {}, "selectedCurrency": "usd" diff --git a/package.json b/package.json index 2aa61ed46e5..333265fecb5 100644 --- a/package.json +++ b/package.json @@ -339,7 +339,7 @@ "@metamask/superstruct": "^3.2.1", "@metamask/swappable-obj-proxy": "^2.1.0", "@metamask/transaction-controller": "^65.0.0", - "@metamask/transaction-pay-controller": "^20.0.0", + "@metamask/transaction-pay-controller": "^21.0.0", "@metamask/tron-wallet-snap": "^1.25.3", "@metamask/utils": "^11.11.0", "@myx-trade/sdk": "^0.1.265", diff --git a/tests/api-mocking/mock-responses/defaults/rpc-endpoints.ts b/tests/api-mocking/mock-responses/defaults/rpc-endpoints.ts index fd003339282..c22266761b0 100644 --- a/tests/api-mocking/mock-responses/defaults/rpc-endpoints.ts +++ b/tests/api-mocking/mock-responses/defaults/rpc-endpoints.ts @@ -51,5 +51,14 @@ export const DEFAULT_RPC_ENDPOINT_MOCKS: MockEventsObject = { result: '0x0', }, }, + { + urlEndpoint: 'https://testnet-rpc.monad.xyz/', + responseCode: 200, + response: { + jsonrpc: '2.0', + id: 1, + result: '0x0', + }, + }, ], }; diff --git a/yarn.lock b/yarn.lock index 9d5d1b0345b..787872a8cd3 100644 --- a/yarn.lock +++ b/yarn.lock @@ -7882,28 +7882,28 @@ __metadata: languageName: node linkType: hard -"@metamask/assets-controller@npm:^6.1.0, @metamask/assets-controller@npm:^6.2.0, @metamask/assets-controller@npm:^6.2.1": - version: 6.2.1 - resolution: "@metamask/assets-controller@npm:6.2.1" +"@metamask/assets-controller@npm:^6.2.1, @metamask/assets-controller@npm:^6.3.0": + version: 6.3.0 + resolution: "@metamask/assets-controller@npm:6.3.0" 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.1.0" "@metamask/accounts-controller": "npm:^37.2.0" - "@metamask/assets-controllers": "npm:^105.0.0" + "@metamask/assets-controllers": "npm:^105.1.0" "@metamask/base-controller": "npm:^9.1.0" "@metamask/client-controller": "npm:^1.0.1" "@metamask/controller-utils": "npm:^11.20.0" "@metamask/core-backend": "npm:^6.2.1" - "@metamask/keyring-api": "npm:^23.0.1" - "@metamask/keyring-controller": "npm:^25.2.0" - "@metamask/keyring-internal-api": "npm:^10.1.1" - "@metamask/keyring-snap-client": "npm:^9.0.1" - "@metamask/messenger": "npm:^1.1.1" - "@metamask/network-controller": "npm:^30.0.1" + "@metamask/keyring-api": "npm:^23.1.0" + "@metamask/keyring-controller": "npm:^25.3.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:^30.1.0" "@metamask/network-enablement-controller": "npm:^5.0.2" - "@metamask/permission-controller": "npm:^12.3.0" + "@metamask/permission-controller": "npm:^13.0.0" "@metamask/phishing-controller": "npm:^17.1.1" "@metamask/polling-controller": "npm:^16.0.4" "@metamask/preferences-controller": "npm:^23.1.0" @@ -7915,13 +7915,13 @@ __metadata: bignumber.js: "npm:^9.1.2" lodash: "npm:^4.17.21" p-limit: "npm:^3.1.0" - checksum: 10/32645ec9dc88199a93d72498cf8a00a0b8fc5a34c889229a80d7ec972acbb20eafbfe978a5ee98619f003a8a671de75ca96ed8e283cffb2208b2fc0fd893ee71 + checksum: 10/a1c2511ec5f954b78778684f116d09b177fa1a38458f83c8ac2b155b925724917c038a9b1dbc57b1e4092cb64df2464af39314d897139474c398bab88cd755f7 languageName: node linkType: hard -"@metamask/assets-controllers@npm:^104.3.0": - version: 104.3.0 - resolution: "@metamask/assets-controllers@npm:104.3.0" +"@metamask/assets-controllers@npm:^105.0.0, @metamask/assets-controllers@npm:^105.1.0": + version: 105.1.0 + resolution: "@metamask/assets-controllers@npm:105.1.0" dependencies: "@ethereumjs/util": "npm:^9.1.0" "@ethersproject/abi": "npm:^5.7.0" @@ -7938,70 +7938,14 @@ __metadata: "@metamask/controller-utils": "npm:^11.20.0" "@metamask/core-backend": "npm:^6.2.1" "@metamask/eth-query": "npm:^4.0.0" - "@metamask/keyring-api": "npm:^23.0.1" - "@metamask/keyring-controller": "npm:^25.2.0" - "@metamask/messenger": "npm:^1.1.1" - "@metamask/metamask-eth-abis": "npm:^3.1.1" - "@metamask/multichain-account-service": "npm:^8.0.1" - "@metamask/network-controller": "npm:^30.0.1" - "@metamask/network-enablement-controller": "npm:^5.0.2" - "@metamask/permission-controller": "npm:^12.3.0" - "@metamask/phishing-controller": "npm:^17.1.1" - "@metamask/polling-controller": "npm:^16.0.4" - "@metamask/preferences-controller": "npm:^23.1.0" - "@metamask/profile-sync-controller": "npm:^28.0.2" - "@metamask/rpc-errors": "npm:^7.0.2" - "@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:^64.3.0" - "@metamask/utils": "npm:^11.9.0" - "@types/bn.js": "npm:^5.1.5" - "@types/uuid": "npm:^8.3.0" - async-mutex: "npm:^0.5.0" - bitcoin-address-validation: "npm:^2.2.3" - bn.js: "npm:^5.2.1" - immer: "npm:^9.0.6" - lodash: "npm:^4.17.21" - multiformats: "npm:^9.9.0" - reselect: "npm:^5.1.1" - single-call-balance-checker-abi: "npm:^1.0.0" - uuid: "npm:^8.3.2" - peerDependencies: - "@metamask/providers": ^22.0.0 - webextension-polyfill: ^0.10.0 || ^0.11.0 || ^0.12.0 - checksum: 10/9dbab56816788a66ca1dacf1aa476455115c3f1bb53ac2b809c096dee0b43e3dd641a15ea6ec192dc088cfbeb0d84c2bddd4d19d4f0b732aa7c3e7befe64a1ea - languageName: node - linkType: hard - -"@metamask/assets-controllers@npm:^105.0.0": - version: 105.0.0 - resolution: "@metamask/assets-controllers@npm:105.0.0" - dependencies: - "@ethereumjs/util": "npm:^9.1.0" - "@ethersproject/abi": "npm:^5.7.0" - "@ethersproject/address": "npm:^5.7.0" - "@ethersproject/bignumber": "npm:^5.7.0" - "@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.1.0" - "@metamask/accounts-controller": "npm:^37.2.0" - "@metamask/approval-controller": "npm:^9.0.1" - "@metamask/base-controller": "npm:^9.1.0" - "@metamask/contract-metadata": "npm:^2.4.0" - "@metamask/controller-utils": "npm:^11.20.0" - "@metamask/core-backend": "npm:^6.2.1" - "@metamask/eth-query": "npm:^4.0.0" - "@metamask/keyring-api": "npm:^23.0.1" - "@metamask/keyring-controller": "npm:^25.2.0" - "@metamask/messenger": "npm:^1.1.1" + "@metamask/keyring-api": "npm:^23.1.0" + "@metamask/keyring-controller": "npm:^25.3.0" + "@metamask/messenger": "npm:^1.2.0" "@metamask/metamask-eth-abis": "npm:^3.1.1" "@metamask/multichain-account-service": "npm:^8.0.1" - "@metamask/network-controller": "npm:^30.0.1" + "@metamask/network-controller": "npm:^30.1.0" "@metamask/network-enablement-controller": "npm:^5.0.2" - "@metamask/permission-controller": "npm:^12.3.0" + "@metamask/permission-controller": "npm:^13.0.0" "@metamask/phishing-controller": "npm:^17.1.1" "@metamask/polling-controller": "npm:^16.0.4" "@metamask/preferences-controller": "npm:^23.1.0" @@ -8027,7 +7971,7 @@ __metadata: peerDependencies: "@metamask/providers": ^22.0.0 webextension-polyfill: ^0.10.0 || ^0.11.0 || ^0.12.0 - checksum: 10/5b0d0b5e96f0e34bef7607574490db28cc5d844f5f57277c5b895626d13f842fceaa714c2a4b52400d0f4d333a1f1af95b5d4db34d81d2906a5d0c7bfa189727 + checksum: 10/206289f0fe122f228c7669a3a11c54ffbc07739a359193b8f8a10e0223a6e58b49c3a435fae4e18ab78ad9d2a03a9cb1758ba5ec2c8b08c618ff28c4dafd5e02 languageName: node linkType: hard @@ -8126,39 +8070,6 @@ __metadata: languageName: node linkType: hard -"@metamask/bridge-controller@npm:^70.2.0": - version: 70.2.0 - resolution: "@metamask/bridge-controller@npm:70.2.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:^37.2.0" - "@metamask/assets-controller": "npm:^6.1.0" - "@metamask/assets-controllers": "npm:^104.3.0" - "@metamask/base-controller": "npm:^9.1.0" - "@metamask/controller-utils": "npm:^11.20.0" - "@metamask/gas-fee-controller": "npm:^26.1.1" - "@metamask/keyring-api": "npm:^23.0.1" - "@metamask/messenger": "npm:^1.1.1" - "@metamask/metamask-eth-abis": "npm:^3.1.1" - "@metamask/multichain-network-controller": "npm:^3.0.6" - "@metamask/network-controller": "npm:^30.0.1" - "@metamask/polling-controller": "npm:^16.0.4" - "@metamask/profile-sync-controller": "npm:^28.0.2" - "@metamask/remote-feature-flag-controller": "npm:^4.2.0" - "@metamask/snaps-controllers": "npm:^19.0.0" - "@metamask/transaction-controller": "npm:^64.3.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/5e3ff900bbcdbe2bee0e143cfb06076fb0a27d1c582d88dff9eba57458207dc2c76a47b178403ae4c36ec8a48e5fb1ddbefc310f73a4baaf24b4528a488514b1 - languageName: node - linkType: hard - "@metamask/bridge-controller@npm:^71.0.0": version: 71.0.0 resolution: "@metamask/bridge-controller@npm:71.0.0" @@ -8192,7 +8103,7 @@ __metadata: languageName: node linkType: hard -"@metamask/bridge-status-controller@npm:71.1.0": +"@metamask/bridge-status-controller@npm:71.1.0, @metamask/bridge-status-controller@npm:^71.1.0": version: 71.1.0 resolution: "@metamask/bridge-status-controller@npm:71.1.0" dependencies: @@ -8960,17 +8871,18 @@ __metadata: languageName: node linkType: hard -"@metamask/json-rpc-engine@npm:^10.0.0, @metamask/json-rpc-engine@npm:^10.0.2, @metamask/json-rpc-engine@npm:^10.0.3, @metamask/json-rpc-engine@npm:^10.1.0, @metamask/json-rpc-engine@npm:^10.1.1, @metamask/json-rpc-engine@npm:^10.2.3, @metamask/json-rpc-engine@npm:^10.2.4": - version: 10.2.4 - resolution: "@metamask/json-rpc-engine@npm:10.2.4" +"@metamask/json-rpc-engine@npm:^10.0.0, @metamask/json-rpc-engine@npm:^10.0.2, @metamask/json-rpc-engine@npm:^10.0.3, @metamask/json-rpc-engine@npm:^10.1.0, @metamask/json-rpc-engine@npm:^10.1.1, @metamask/json-rpc-engine@npm:^10.2.3, @metamask/json-rpc-engine@npm:^10.2.4, @metamask/json-rpc-engine@npm:^10.3.0": + version: 10.3.0 + resolution: "@metamask/json-rpc-engine@npm:10.3.0" dependencies: + "@metamask/messenger": "npm:^1.2.0" "@metamask/rpc-errors": "npm:^7.0.2" "@metamask/safe-event-emitter": "npm:^3.0.0" "@metamask/utils": "npm:^11.9.0" "@types/deep-freeze-strict": "npm:^1.1.0" deep-freeze-strict: "npm:^1.1.1" klona: "npm:^2.0.6" - checksum: 10/b207dd2a9a44674c141c2e027c082974464a37beada98a27e80fe59c9bd44e2c2a992edf8a8d7e3ed461fa27ed372c95d4e27df18752b558c10bf540b7fe7bcd + checksum: 10/8d4da5d933e4be2a85783871b6f1282763cbb5bc559e3228da099c75517530e3ac42a040109f17a4d4ff768f1c8cbcc4358f5e06b820b893af29a13f95180bd6 languageName: node linkType: hard @@ -9024,7 +8936,7 @@ __metadata: languageName: node linkType: hard -"@metamask/keyring-controller@npm:^25.1.0, @metamask/keyring-controller@npm:^25.1.1, @metamask/keyring-controller@npm:^25.2.0, @metamask/keyring-controller@npm:^25.4.0": +"@metamask/keyring-controller@npm:^25.1.0, @metamask/keyring-controller@npm:^25.1.1, @metamask/keyring-controller@npm:^25.2.0, @metamask/keyring-controller@npm:^25.3.0, @metamask/keyring-controller@npm:^25.4.0": version: 25.4.0 resolution: "@metamask/keyring-controller@npm:25.4.0" dependencies: @@ -9118,7 +9030,7 @@ __metadata: languageName: node linkType: hard -"@metamask/keyring-snap-client@npm:^9.0.1, @metamask/keyring-snap-client@npm:^9.0.2": +"@metamask/keyring-snap-client@npm:^9.0.2": version: 9.0.2 resolution: "@metamask/keyring-snap-client@npm:9.0.2" dependencies: @@ -9533,6 +9445,25 @@ __metadata: languageName: node linkType: hard +"@metamask/permission-controller@npm:^13.0.0": + version: 13.0.0 + resolution: "@metamask/permission-controller@npm:13.0.0" + dependencies: + "@metamask/approval-controller": "npm:^9.0.1" + "@metamask/base-controller": "npm:^9.1.0" + "@metamask/controller-utils": "npm:^11.20.0" + "@metamask/json-rpc-engine": "npm:^10.3.0" + "@metamask/messenger": "npm:^1.2.0" + "@metamask/rpc-errors": "npm:^7.0.2" + "@metamask/utils": "npm:^11.9.0" + "@types/deep-freeze-strict": "npm:^1.1.0" + deep-freeze-strict: "npm:^1.1.1" + immer: "npm:^9.0.6" + nanoid: "npm:^3.3.8" + checksum: 10/e4062076f7dd7da7acf890f66ee7df1a0309bbb9d9adb221f28eefb203318c2675707754b884a0d4f49892608a8771443a97e743c2c68f7c75f123ec7fafbf49 + languageName: node + linkType: hard + "@metamask/phishing-controller@npm:^17.1.1": version: 17.1.1 resolution: "@metamask/phishing-controller@npm:17.1.1" @@ -10329,7 +10260,7 @@ __metadata: languageName: node linkType: hard -"@metamask/transaction-controller@npm:^64.0.0, @metamask/transaction-controller@npm:^64.2.0, @metamask/transaction-controller@npm:^64.3.0": +"@metamask/transaction-controller@npm:^64.0.0, @metamask/transaction-controller@npm:^64.2.0": version: 64.4.0 resolution: "@metamask/transaction-controller@npm:64.4.0" dependencies: @@ -10405,23 +10336,23 @@ __metadata: languageName: node linkType: hard -"@metamask/transaction-pay-controller@npm:^20.0.0": - version: 20.0.0 - resolution: "@metamask/transaction-pay-controller@npm:20.0.0" +"@metamask/transaction-pay-controller@npm:^21.0.0": + version: 21.0.0 + resolution: "@metamask/transaction-pay-controller@npm:21.0.0" dependencies: "@ethersproject/abi": "npm:^5.7.0" "@ethersproject/contracts": "npm:^5.7.0" "@ethersproject/providers": "npm:^5.7.0" - "@metamask/assets-controller": "npm:^6.2.0" - "@metamask/assets-controllers": "npm:^104.3.0" + "@metamask/assets-controller": "npm:^6.3.0" + "@metamask/assets-controllers": "npm:^105.1.0" "@metamask/base-controller": "npm:^9.1.0" - "@metamask/bridge-controller": "npm:^70.2.0" - "@metamask/bridge-status-controller": "npm:^71.0.0" + "@metamask/bridge-controller": "npm:^71.0.0" + "@metamask/bridge-status-controller": "npm:^71.1.0" "@metamask/controller-utils": "npm:^11.20.0" "@metamask/gas-fee-controller": "npm:^26.1.1" - "@metamask/messenger": "npm:^1.1.1" + "@metamask/messenger": "npm:^1.2.0" "@metamask/metamask-eth-abis": "npm:^3.1.1" - "@metamask/network-controller": "npm:^30.0.1" + "@metamask/network-controller": "npm:^30.1.0" "@metamask/ramps-controller": "npm:^13.2.0" "@metamask/remote-feature-flag-controller": "npm:^4.2.0" "@metamask/transaction-controller": "npm:^65.0.0" @@ -10430,7 +10361,7 @@ __metadata: bn.js: "npm:^5.2.1" immer: "npm:^9.0.6" lodash: "npm:^4.17.21" - checksum: 10/c67e9e911711dda45973f053ef1c4dd3c825f72749263d694a6a01279f06bfbe958c9b1f82ea2c798fc555adae3ca5936c66a63c141e355d588f692752f17807 + checksum: 10/090dc5efad84ceb2f956b30cec3544125080dc6f9ff5b6030f6446ceca59e9ea0c085309a780f9ffbaa75d3263c067e89fdc7f50ea3354f031ccb465c630ec48 languageName: node linkType: hard @@ -35812,7 +35743,7 @@ __metadata: "@metamask/test-dapp-multichain": "npm:^0.17.1" "@metamask/test-dapp-solana": "npm:^0.3.0" "@metamask/transaction-controller": "npm:^65.0.0" - "@metamask/transaction-pay-controller": "npm:^20.0.0" + "@metamask/transaction-pay-controller": "npm:^21.0.0" "@metamask/tron-wallet-snap": "npm:^1.25.3" "@metamask/utils": "npm:^11.11.0" "@myx-trade/sdk": "npm:^0.1.265" From 08675afc8a0751295e1b3e5b7d112893762bd03c Mon Sep 17 00:00:00 2001 From: VGR Date: Wed, 6 May 2026 12:02:59 +0200 Subject: [PATCH 6/9] feat(rewards): add Perps Trading campaign participant outcome (#29648) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ## **Description** Adds Perps Trading campaign outcome support to the Rewards stack, mirroring the Ondo GM outcome pattern. After a Perps Trading campaign ends, opted-in participants can query `GET /perps-trading/:campaignId/outcome/me` to learn whether they won. This PR wires that endpoint into the mobile app end-to-end: - **Winners** (have a `winnerVerificationCode` + `pending` status) see a toast → tap → `PerpsTradingCampaignWinningView` showing their rank, verification code (copy or email to claim prize) - **Non-winners once results are final** (`finalized` status, no code) see a toast → tap → campaigns view ## **Changelog** CHANGELOG entry: null ## **Related issues** Fixes: rwds-perps-trading-outcome ## **Changes** | Layer | File | What | |---|---|---| | Types | `types.ts` | `CampaignType.PERPS_TRADING`, `PerpsTradingCampaignParticipantOutcomeDto` | | Data service | `rewards-data-service.ts` | `getPerpsTradingCampaignParticipantOutcome()` → `GET /perps-trading/:campaignId/outcome/me` | | Controller | `RewardsController.ts` | Method with 10-min in-memory cache + auth retry | | Action types / Messenger | multiple | Registered in action union and allowed actions | | Hook | `usePerpsTradingCampaignParticipantOutcome.ts` | Fetches outcome via controller messenger | | Toast hook | `usePerpsTradingCampaignEndedOutcomeToast.ts` | `winner_pending` → winning view; `participant_finalized` → campaigns | | Screen | `PerpsTradingCampaignWinningView.tsx` | Rank + verification code, copy + mailto | | Utility | `formatUtils.ts` | `formatOrdinalRank()` (1st/2nd/3rd…) | | Routes / Navigator | multiple | New route constant, screen registered, toast hook wired | | Strings | `en.json` | Toast + winning view copy | ## **Manual testing steps** ```gherkin Feature: Perps Trading campaign outcome Scenario: winner sees outcome toast and winning view Given a Perps Trading campaign that has ended And the current user is opted in and has a winner row with status "pending" and a verification code When the user opens the Rewards section Then a toast appears: "You won! 🏆" with "View details" link When the user taps "View details" Then the PerpsTradingCampaignWinningView opens showing their rank and verification code And the user can copy the code or tap "Open mail" to email perpscampaign@consensys.net Scenario: non-winner sees finalized toast Given a Perps Trading campaign that has ended with 20 finalized winners And the current user is opted in but has no winner row When the user opens the Rewards section Then a toast appears: "The results are in" with "View" link When the user taps "View" Then the user is navigated to the campaigns view ``` ## **Screenshots/Recordings** ### **Before** No Perps Trading outcome UI. ### **After** Simulator Screenshot - E2E Test -
2026-05-05 at 19 33 02 Simulator Screenshot - E2E Test -
2026-05-05 at 20 50 09 Simulator Screenshot - E2E Test -
2026-05-05 at 20 53 05 Simulator Screenshot - E2E Test -
2026-05-05 at 20 53 10 _(to be added — requires a completed Perps Trading campaign with backend data)_ ## **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. ## **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] > **Medium Risk** > Adds new post-campaign outcome UI/UX (toasts, auto-navigation, new routes/screens) that can affect user navigation and state gating across Rewards; issues would mainly surface as incorrect redirects or missing banners/toasts. > > **Overview** > Adds a shared `CampaignWinningView` and wires both Ondo and Perps campaigns to use it for winner verification-code display (copy + mailto) with a consistent fallback when no code is available. > > Introduces a generic `useCampaignParticipantOutcome` + `useCampaignOutcomeToast` pattern, then adds Perps Trading support via `usePerpsTradingCampaignParticipantOutcome` and `usePerpsTradingCampaignEndedOutcomeToast`, mounting these toasts on `RewardsDashboard` and `CampaignsView`. > > Expands Perps Trading end-of-campaign UX: new `PerpsTradingCampaignWinningView` route/screen, outcome banners on details/stats (with one-time session auto-navigation for pending winners), an ended-campaign stats panel, and tweaks to completed-campaign stat presentation (hide pending tag; hide volume/margin once complete). Also consolidates outcome banner locale keys and generalizes `CampaignOutcomeBanner` usage across campaigns. > > Reviewed by [Cursor Bugbot](https://cursor.com/bugbot) for commit 464bd29cc72bbf82d22a06102c13482e1e5fd437. Bugbot is set up for automated code reviews on this repo. Configure [here](https://www.cursor.com/dashboard/bugbot). --------- Co-authored-by: sophieqgu --- .../UI/Rewards/RewardsNavigator.test.tsx | 2 + .../UI/Rewards/RewardsNavigator.tsx | 7 +- .../Views/CampaignWinningView.test.tsx | 297 +++++++++ .../UI/Rewards/Views/CampaignWinningView.tsx | 267 ++++++++ .../UI/Rewards/Views/CampaignsView.test.tsx | 28 +- .../UI/Rewards/Views/CampaignsView.tsx | 6 +- .../Views/OndoCampaignStatsView.test.tsx | 10 +- .../Rewards/Views/OndoCampaignStatsView.tsx | 4 +- .../Views/OndoCampaignWinningView.test.tsx | 464 ++++---------- .../Rewards/Views/OndoCampaignWinningView.tsx | 247 +------- .../PerpsTradingCampaignDetailsView.test.tsx | 294 ++++++++- .../Views/PerpsTradingCampaignDetailsView.tsx | 107 +++- .../PerpsTradingCampaignLeaderboardView.tsx | 1 + .../PerpsTradingCampaignStatsView.test.tsx | 41 ++ .../Views/PerpsTradingCampaignStatsView.tsx | 62 +- .../PerpsTradingCampaignWinningView.test.tsx | 206 +++++++ .../Views/PerpsTradingCampaignWinningView.tsx | 80 +++ .../Rewards/Views/RewardsDashboard.test.tsx | 22 + .../UI/Rewards/Views/RewardsDashboard.tsx | 5 +- ...st.tsx => CampaignOutcomeBanners.test.tsx} | 62 +- ...Banners.tsx => CampaignOutcomeBanners.tsx} | 51 +- .../OndoCampaignStatsSummary.test.tsx | 4 +- .../Campaigns/OndoCampaignStatsSummary.tsx | 4 +- .../PerpsCampaignStatsSummary.test.tsx | 76 ++- .../Campaigns/PerpsCampaignStatsSummary.tsx | 42 +- .../PerpsTradingCampaignEndedStats.test.tsx | 276 +++++++++ .../PerpsTradingCampaignEndedStats.tsx | 153 +++++ .../PerpsTradingCampaignStatsHeader.test.tsx | 10 + .../PerpsTradingCampaignStatsHeader.tsx | 5 +- .../ReferralDetails/CopyableField.tsx | 10 +- .../hooks/useCampaignOutcomeToast.test.ts | 500 +++++++++++++++ .../Rewards/hooks/useCampaignOutcomeToast.ts | 174 ++++++ .../useCampaignParticipantOutcome.test.ts | 173 ++++++ .../hooks/useCampaignParticipantOutcome.ts | 68 +++ .../hooks/useGetOndoCampaignActivity.test.ts | 8 +- .../hooks/useGetOndoCampaignActivity.ts | 6 +- .../useGetOndoLeaderboardPosition.test.ts | 16 +- .../hooks/useGetOndoLeaderboardPosition.ts | 6 +- .../hooks/useGetOndoPortfolioPosition.test.ts | 8 +- .../hooks/useGetOndoPortfolioPosition.ts | 6 +- ...TradingCampaignLeaderboardPosition.test.ts | 16 +- ...PerpsTradingCampaignLeaderboardPosition.ts | 6 +- .../hooks/useLinkAccountAddress.test.ts | 8 + .../Rewards/hooks/useLinkAccountGroup.test.ts | 8 + .../useOndoCampaignParticipantOutcome.test.ts | 156 ++--- .../useOndoCampaignParticipantOutcome.ts | 59 +- .../Rewards/hooks/useOndoOutcomeToast.test.ts | 570 ++---------------- .../UI/Rewards/hooks/useOndoOutcomeToast.ts | 167 +---- .../Rewards/hooks/useOptInToCampaign.test.ts | 31 +- .../UI/Rewards/hooks/useOptInToCampaign.ts | 16 +- ...psTradingCampaignEndedOutcomeToast.test.ts | 98 +++ ...sePerpsTradingCampaignEndedOutcomeToast.ts | 21 + ...sTradingCampaignParticipantOutcome.test.ts | 68 +++ ...ePerpsTradingCampaignParticipantOutcome.ts | 22 + .../UI/Rewards/hooks/useRewardsToast.test.tsx | 62 ++ .../UI/Rewards/hooks/useRewardsToast.tsx | 71 ++- app/components/UI/Rewards/utils.ts | 1 + app/constants/navigation/Routes.ts | 2 + .../RewardsController-method-action-types.ts | 14 + .../RewardsController.test.ts | 155 +++++ .../rewards-controller/RewardsController.ts | 64 ++ .../services/rewards-data-service.test.ts | 53 ++ .../services/rewards-data-service.ts | 30 +- .../controllers/rewards-controller/types.ts | 25 +- .../rewards-controller-messenger/index.ts | 5 +- app/reducers/rewards/index.test.ts | 40 +- app/reducers/rewards/index.ts | 2 +- app/reducers/rewards/selectors.test.ts | 44 +- app/reducers/rewards/selectors.ts | 9 + app/selectors/rewards/index.ts | 11 - locales/languages/de.json | 44 +- locales/languages/el.json | 44 +- locales/languages/en.json | 34 +- locales/languages/es.json | 44 +- locales/languages/fr.json | 44 +- locales/languages/hi.json | 44 +- locales/languages/id.json | 44 +- locales/languages/ja.json | 44 +- locales/languages/ko.json | 44 +- locales/languages/pt.json | 44 +- locales/languages/ru.json | 44 +- locales/languages/tl.json | 44 +- locales/languages/tr.json | 44 +- locales/languages/vi.json | 44 +- locales/languages/zh.json | 44 +- 85 files changed, 4352 insertions(+), 1940 deletions(-) create mode 100644 app/components/UI/Rewards/Views/CampaignWinningView.test.tsx create mode 100644 app/components/UI/Rewards/Views/CampaignWinningView.tsx create mode 100644 app/components/UI/Rewards/Views/PerpsTradingCampaignWinningView.test.tsx create mode 100644 app/components/UI/Rewards/Views/PerpsTradingCampaignWinningView.tsx rename app/components/UI/Rewards/components/Campaigns/{OndoCampaignOutcomeBanners.test.tsx => CampaignOutcomeBanners.test.tsx} (67%) rename app/components/UI/Rewards/components/Campaigns/{OndoCampaignOutcomeBanners.tsx => CampaignOutcomeBanners.tsx} (53%) create mode 100644 app/components/UI/Rewards/components/Campaigns/PerpsTradingCampaignEndedStats.test.tsx create mode 100644 app/components/UI/Rewards/components/Campaigns/PerpsTradingCampaignEndedStats.tsx create mode 100644 app/components/UI/Rewards/hooks/useCampaignOutcomeToast.test.ts create mode 100644 app/components/UI/Rewards/hooks/useCampaignOutcomeToast.ts create mode 100644 app/components/UI/Rewards/hooks/useCampaignParticipantOutcome.test.ts create mode 100644 app/components/UI/Rewards/hooks/useCampaignParticipantOutcome.ts create mode 100644 app/components/UI/Rewards/hooks/usePerpsTradingCampaignEndedOutcomeToast.test.ts create mode 100644 app/components/UI/Rewards/hooks/usePerpsTradingCampaignEndedOutcomeToast.ts create mode 100644 app/components/UI/Rewards/hooks/usePerpsTradingCampaignParticipantOutcome.test.ts create mode 100644 app/components/UI/Rewards/hooks/usePerpsTradingCampaignParticipantOutcome.ts diff --git a/app/components/UI/Rewards/RewardsNavigator.test.tsx b/app/components/UI/Rewards/RewardsNavigator.test.tsx index e903bafb8ec..b824e326b1c 100644 --- a/app/components/UI/Rewards/RewardsNavigator.test.tsx +++ b/app/components/UI/Rewards/RewardsNavigator.test.tsx @@ -282,6 +282,8 @@ jest.mock('./hooks/useRewardsToast', () => ({ loading: jest.fn(), entriesClosed: jest.fn(), enableNotificationsNudge: jest.fn(), + outcomeWinner: jest.fn(), + outcomeNonWinner: jest.fn(), }, })), })); diff --git a/app/components/UI/Rewards/RewardsNavigator.tsx b/app/components/UI/Rewards/RewardsNavigator.tsx index f9aabffa47a..77ab123834e 100644 --- a/app/components/UI/Rewards/RewardsNavigator.tsx +++ b/app/components/UI/Rewards/RewardsNavigator.tsx @@ -37,9 +37,9 @@ import { useReferralDetails } from './hooks/useReferralDetails'; import { useRewardsNotificationsNudge } from './hooks/useRewardsNotificationsNudge'; import useRewardsToast from './hooks/useRewardsToast'; import { strings } from '../../../../locales/i18n'; +import PerpsTradingCampaignWinningView from './Views/PerpsTradingCampaignWinningView'; let sessionNotificationsNudgeShown = false; - const Stack = createStackNavigator(); const RewardsNavigator: React.FC = () => { @@ -296,6 +296,11 @@ const RewardsNavigator: React.FC = () => { component={PerpsTradingCampaignStatsView} options={{ headerShown: false }} /> + ) : null} diff --git a/app/components/UI/Rewards/Views/CampaignWinningView.test.tsx b/app/components/UI/Rewards/Views/CampaignWinningView.test.tsx new file mode 100644 index 00000000000..afbc489f176 --- /dev/null +++ b/app/components/UI/Rewards/Views/CampaignWinningView.test.tsx @@ -0,0 +1,297 @@ +import React from 'react'; +import { render, fireEvent } from '@testing-library/react-native'; +import { Linking } from 'react-native'; +import Clipboard from '@react-native-clipboard/clipboard'; +import CampaignWinningView, { + CampaignWinningViewProps, +} from './CampaignWinningView'; +import useTrackRewardsPageView from '../hooks/useTrackRewardsPageView'; + +jest.mock('../../../../images/rewards/campaign_winning.png', () => ({ + __esModule: true, + default: 1, +})); + +const mockGoBack = jest.fn(); +const mockNavigate = jest.fn(); + +jest.mock('@react-navigation/native', () => ({ + useNavigation: () => ({ goBack: mockGoBack, navigate: mockNavigate }), +})); + +jest.mock('@metamask/design-system-twrnc-preset', () => { + const tw = (...args: unknown[]) => args; + tw.style = (...args: unknown[]) => args; + return { useTailwind: () => tw }; +}); + +jest.mock('react-native/Libraries/Utilities/useWindowDimensions', () => ({ + default: () => ({ width: 390, height: 844 }), +})); + +jest.mock('react-native-safe-area-context', () => { + const actual = jest.requireActual('react-native-safe-area-context'); + return { + ...actual, + useSafeAreaInsets: () => ({ top: 44, bottom: 34, left: 0, right: 0 }), + }; +}); + +jest.mock('../../../Views/ErrorBoundary', () => { + const ReactActual = jest.requireActual('react'); + const { View } = jest.requireActual('react-native'); + return { + __esModule: true, + default: ({ children }: { children: React.ReactNode }) => + ReactActual.createElement(View, null, children), + }; +}); + +jest.mock('../hooks/useTrackRewardsPageView', () => ({ + __esModule: true, + default: jest.fn(), +})); + +jest.mock('../../../../core/Analytics', () => ({ + MetaMetricsEvents: { + REWARDS_PAGE_BUTTON_CLICKED: 'REWARDS_PAGE_BUTTON_CLICKED', + }, +})); + +jest.mock('../utils', () => ({ + RewardsMetricsButtons: { + COPY_WINNER_VERIFICATION_CODE: 'copy_winner_verification_code', + }, +})); + +const mockTrackEvent = jest.fn(); +const mockBuild = jest.fn(() => ({})); +jest.mock('../../../hooks/useAnalytics/useAnalytics', () => ({ + useAnalytics: () => ({ + trackEvent: mockTrackEvent, + createEventBuilder: () => ({ + addProperties: () => ({ build: mockBuild }), + }), + }), +})); + +jest.mock('../components/ReferralDetails/CopyableField', () => { + const ReactActual = jest.requireActual('react'); + const { View, Text, Pressable } = jest.requireActual('react-native'); + return { + __esModule: true, + default: ({ + label, + value, + onCopy, + }: { + label: string; + value?: string | null; + onCopy?: () => void; + }) => + ReactActual.createElement( + View, + { testID: 'copyable-field' }, + ReactActual.createElement(Text, null, label), + ReactActual.createElement( + Text, + { testID: 'copyable-value' }, + value ?? '', + ), + ReactActual.createElement(Pressable, { + testID: 'copyable-trigger', + onPress: onCopy, + }), + ), + }; +}); + +jest.mock('../../../../../locales/i18n', () => ({ + strings: jest.fn( + ( + key: string, + params?: { + code?: string; + campaignName?: string; + email?: string; + }, + ) => { + if ( + key === 'rewards.campaign_winning.mail_subject' && + params?.campaignName + ) + return `${params.campaignName} prize claim`; + if (key === 'rewards.campaign_winning.mail_body' && params?.code) + return `My winning code: ${params.code}`; + if ( + key === 'rewards.campaign_winning.email_instructions' && + params?.email + ) + return `Email ${params.email} with your code`; + const map: Record = { + 'rewards.campaign_winning.you_won': 'You won', + 'rewards.campaign_winning.open_mail': 'Open mail', + 'rewards.campaign_winning.skip_for_now': 'Skip for now', + 'rewards.campaign_winning.winning_code': 'Winning code', + 'rewards.campaign_winning.close_a11y': 'Close', + }; + return map[key] ?? key; + }, + ), +})); + +const PRIZE_EMAIL = 'test@consensys.net'; +const CAMPAIGN_NAME = 'Test Campaign'; +const CAMPAIGN_ID = 'campaign-test-1'; +const WINNING_CODE = 'WIN-123'; +const mockUseTrackRewardsPageView = + useTrackRewardsPageView as jest.MockedFunction< + typeof useTrackRewardsPageView + >; + +const defaultProps: CampaignWinningViewProps = { + testID: 'test-winning-view', + viewName: 'TestWinningView', + prizeEmail: PRIZE_EMAIL, + campaignName: CAMPAIGN_NAME, + campaignId: CAMPAIGN_ID, + analyticsPageType: 'test_campaign_winning', + winningCode: WINNING_CODE, + hasOutcomeLoaded: true, + isLoading: false, + rankDisplay: null, +}; + +describe('CampaignWinningView', () => { + beforeEach(() => { + jest.clearAllMocks(); + }); + + it('renders the main container with the provided testID', () => { + const { getByTestId } = render(); + expect(getByTestId('test-winning-view')).toBeTruthy(); + }); + + it('renders "You won" text', () => { + const { getByText } = render(); + expect(getByText('You won')).toBeTruthy(); + }); + + it('renders email instructions with the prizeEmail', () => { + const { getByText } = render(); + expect(getByText(`Email ${PRIZE_EMAIL} with your code`)).toBeTruthy(); + }); + + it('tracks page view with the campaign id', () => { + render(); + expect(mockUseTrackRewardsPageView).toHaveBeenCalledWith({ + page_type: 'test_campaign_winning', + campaign_id: CAMPAIGN_ID, + }); + }); + + it('renders rank and result display when provided', () => { + const { getByText } = render( + , + ); + expect(getByText('3rd')).toBeTruthy(); + expect(getByText('+12.34%')).toBeTruthy(); + }); + + it('calls goBack when Skip for now is pressed', () => { + const { getByText } = render(); + fireEvent.press(getByText('Skip for now')); + expect(mockGoBack).toHaveBeenCalledTimes(1); + }); + + it('calls goBack when Close button is pressed', () => { + const { getByLabelText } = render( + , + ); + fireEvent.press(getByLabelText('Close')); + expect(mockGoBack).toHaveBeenCalledTimes(1); + }); + + it('copies winning code and fires analytics when copy is triggered', () => { + const setStringSpy = jest.spyOn(Clipboard, 'setString'); + const { getByTestId } = render(); + fireEvent.press(getByTestId('copyable-trigger')); + expect(setStringSpy).toHaveBeenCalledWith(WINNING_CODE); + expect(mockTrackEvent).toHaveBeenCalled(); + setStringSpy.mockRestore(); + }); + + it('opens mailto with the correct email and code when Open mail is pressed', async () => { + const openSpy = jest.spyOn(Linking, 'openURL').mockResolvedValue(undefined); + const { getByText } = render(); + fireEvent.press(getByText('Open mail')); + expect(openSpy).toHaveBeenCalled(); + const url = openSpy.mock.calls[0][0] as string; + expect(url).toContain(`mailto:${PRIZE_EMAIL}`); + expect(url).toContain(encodeURIComponent(WINNING_CODE)); + openSpy.mockRestore(); + }); + + it('navigates to fallback route when outcome loads without a winning code', () => { + const fallbackRoute = { + route: 'CampaignDetails', + params: { campaignId: CAMPAIGN_ID }, + }; + + render( + , + ); + + expect(mockNavigate).toHaveBeenCalledWith( + fallbackRoute.route, + fallbackRoute.params, + ); + expect(mockGoBack).not.toHaveBeenCalled(); + }); + + it('falls back to goBack when outcome loads without a winning code and no fallback route is provided', () => { + render( + , + ); + expect(mockGoBack).toHaveBeenCalledTimes(1); + }); + + it('does not call goBack before outcome has loaded', () => { + render( + , + ); + expect(mockGoBack).not.toHaveBeenCalled(); + }); + + it('does not call goBack while still loading', () => { + render( + , + ); + expect(mockGoBack).not.toHaveBeenCalled(); + }); +}); diff --git a/app/components/UI/Rewards/Views/CampaignWinningView.tsx b/app/components/UI/Rewards/Views/CampaignWinningView.tsx new file mode 100644 index 00000000000..8eb65c54a01 --- /dev/null +++ b/app/components/UI/Rewards/Views/CampaignWinningView.tsx @@ -0,0 +1,267 @@ +import React, { useCallback, useEffect } from 'react'; +import { Image, Linking, ScrollView, useWindowDimensions } from 'react-native'; +import Clipboard from '@react-native-clipboard/clipboard'; +import { + useNavigation, + type NavigationProp, + type ParamListBase, +} from '@react-navigation/native'; +import { + SafeAreaView, + useSafeAreaInsets, +} from 'react-native-safe-area-context'; +import { useTailwind } from '@metamask/design-system-twrnc-preset'; +import { + Box, + BoxFlexDirection, + Button, + ButtonSize, + ButtonVariant, + ButtonIcon, + ButtonIconSize, + IconName, + Skeleton, + Text, + TextColor, + TextVariant, + FontWeight, +} from '@metamask/design-system-react-native'; +import ErrorBoundary from '../../../Views/ErrorBoundary'; +import useTrackRewardsPageView from '../hooks/useTrackRewardsPageView'; +import { strings } from '../../../../../locales/i18n'; +import CopyableField from '../components/ReferralDetails/CopyableField'; +import { RewardsMetricsButtons } from '../utils'; +import { useAnalytics } from '../../../hooks/useAnalytics/useAnalytics'; +import { MetaMetricsEvents } from '../../../../core/Analytics'; +import campaignWinningHero from '../../../../images/rewards/campaign_winning.png'; + +const HERO_HEIGHT_RATIO = 0.5; + +export interface CampaignWinningViewProps { + testID: string; + viewName: string; + prizeEmail: string; + campaignName: string; + campaignId: string; + analyticsPageType: string; + winningCode: string | null; + hasOutcomeLoaded: boolean; + isLoading: boolean; + rankDisplay: string | null; + resultDisplay?: string | null; + isRankLoading?: boolean; + isResultLoading?: boolean; + fallbackRoute?: { + route: string; + params?: object; + }; +} + +const CampaignWinningView: React.FC = ({ + testID, + viewName, + prizeEmail, + campaignName, + campaignId, + analyticsPageType, + winningCode, + hasOutcomeLoaded, + isLoading, + rankDisplay, + resultDisplay = null, + isRankLoading = false, + isResultLoading = false, + fallbackRoute, +}) => { + const tw = useTailwind(); + const { height: windowHeight } = useWindowDimensions(); + const heroHeight = windowHeight * HERO_HEIGHT_RATIO; + const insets = useSafeAreaInsets(); + const navigation = useNavigation>(); + const { trackEvent, createEventBuilder } = useAnalytics(); + + useTrackRewardsPageView({ + page_type: analyticsPageType, + campaign_id: campaignId, + }); + + useEffect(() => { + if (!isLoading && hasOutcomeLoaded && winningCode === null) { + if (fallbackRoute) { + navigation.navigate(fallbackRoute.route, fallbackRoute.params); + return; + } + navigation.goBack(); + } + }, [isLoading, hasOutcomeLoaded, winningCode, fallbackRoute, navigation]); + + const onDismiss = useCallback(() => { + navigation.goBack(); + }, [navigation]); + + const handleCopyWinningCode = useCallback(() => { + if (winningCode) { + Clipboard.setString(winningCode); + trackEvent( + createEventBuilder(MetaMetricsEvents.REWARDS_PAGE_BUTTON_CLICKED) + .addProperties({ + button_type: RewardsMetricsButtons.COPY_WINNER_VERIFICATION_CODE, + }) + .build(), + ); + } + }, [winningCode, trackEvent, createEventBuilder]); + + const handleOpenMail = useCallback(async () => { + const baseSubject = strings('rewards.campaign_winning.mail_subject', { + campaignName, + }); + const subject = winningCode + ? `${baseSubject} - ${winningCode}` + : baseSubject; + const body = strings('rewards.campaign_winning.mail_body', { + code: winningCode || '—', + }); + const url = `mailto:${prizeEmail}?subject=${encodeURIComponent(subject)}&body=${encodeURIComponent(body)}`; + try { + await Linking.openURL(url); + } catch { + // no-op: device may not have a mail handler + } + }, [winningCode, prizeEmail, campaignName]); + + return ( + + + + + + + + + + + + + + {strings('rewards.campaign_winning.you_won')} + + + + {rankDisplay !== null ? ( + + {rankDisplay} + + ) : isRankLoading ? ( + + ) : null} + + {resultDisplay !== null ? ( + + {resultDisplay} + + ) : isResultLoading ? ( + + ) : null} + + + + {strings('rewards.campaign_winning.email_instructions', { + email: prizeEmail, + })} + + + + + + + + + + + + + + + + + ); +}; + +export default CampaignWinningView; diff --git a/app/components/UI/Rewards/Views/CampaignsView.test.tsx b/app/components/UI/Rewards/Views/CampaignsView.test.tsx index 0bca5c0bfef..a811a188841 100644 --- a/app/components/UI/Rewards/Views/CampaignsView.test.tsx +++ b/app/components/UI/Rewards/Views/CampaignsView.test.tsx @@ -6,6 +6,8 @@ import { CampaignType, } from '../../../../core/Engine/controllers/rewards-controller/types'; import { useRewardCampaigns } from '../hooks/useRewardCampaigns'; +import { useOndoOutcomeToast } from '../hooks/useOndoOutcomeToast'; +import { usePerpsTradingCampaignEndedOutcomeToast } from '../hooks/usePerpsTradingCampaignEndedOutcomeToast'; import { REWARDS_VIEW_SELECTORS } from './RewardsView.constants'; const mockGoBack = jest.fn(); @@ -30,11 +32,18 @@ const mockUseRewardCampaigns = useRewardCampaigns as jest.MockedFunction< jest.mock('../hooks/useOndoOutcomeToast', () => ({ useOndoOutcomeToast: jest.fn(), })); -import { useOndoOutcomeToast } from '../hooks/useOndoOutcomeToast'; const mockUseOndoOutcomeToast = useOndoOutcomeToast as jest.MockedFunction< typeof useOndoOutcomeToast >; +jest.mock('../hooks/usePerpsTradingCampaignEndedOutcomeToast', () => ({ + usePerpsTradingCampaignEndedOutcomeToast: jest.fn(), +})); +const mockUsePerpsTradingCampaignEndedOutcomeToast = + usePerpsTradingCampaignEndedOutcomeToast as jest.MockedFunction< + typeof usePerpsTradingCampaignEndedOutcomeToast + >; + jest.mock('../components/Campaigns/CampaignsGroup', () => { const ReactActual = jest.requireActual('react'); const { View, Text } = jest.requireActual('react-native'); @@ -173,7 +182,6 @@ describe('CampaignsView', () => { beforeEach(() => { jest.clearAllMocks(); mockUseRewardCampaigns.mockReturnValue(hookDefaults); - mockUseOndoOutcomeToast.mockReturnValue(undefined); }); it('renders the header with the correct title', () => { @@ -185,6 +193,15 @@ describe('CampaignsView', () => { expect(getByText('Campaigns')).toBeOnTheScreen(); }); + it('mounts campaign outcome toast hooks on render', () => { + render(); + + expect(mockUseOndoOutcomeToast).toHaveBeenCalledTimes(1); + expect(mockUsePerpsTradingCampaignEndedOutcomeToast).toHaveBeenCalledTimes( + 1, + ); + }); + it('navigates back when the back button is pressed', () => { const { getByTestId } = render(); @@ -374,11 +391,4 @@ describe('CampaignsView', () => { expect(queryByText('Refreshing...')).toBeNull(); }); }); - - describe('hook integration', () => { - it('calls useOndoOutcomeToast on render', () => { - render(); - expect(mockUseOndoOutcomeToast).toHaveBeenCalledTimes(1); - }); - }); }); diff --git a/app/components/UI/Rewards/Views/CampaignsView.tsx b/app/components/UI/Rewards/Views/CampaignsView.tsx index b4b89d1ec1d..14e5324ba17 100644 --- a/app/components/UI/Rewards/Views/CampaignsView.tsx +++ b/app/components/UI/Rewards/Views/CampaignsView.tsx @@ -16,11 +16,12 @@ import { SafeAreaView } from 'react-native-safe-area-context'; import ErrorBoundary from '../../../Views/ErrorBoundary'; import HeaderCompactStandard from '../../../../component-library/components-temp/HeaderCompactStandard'; import { useRewardCampaigns } from '../hooks/useRewardCampaigns'; -import { useOndoOutcomeToast } from '../hooks/useOndoOutcomeToast'; import RewardsErrorBanner from '../components/RewardsErrorBanner'; import { REWARDS_VIEW_SELECTORS } from './RewardsView.constants'; import CampaignsGroup from '../components/Campaigns/CampaignsGroup'; import { strings } from '../../../../../locales/i18n'; +import { useOndoOutcomeToast } from '../hooks/useOndoOutcomeToast'; +import { usePerpsTradingCampaignEndedOutcomeToast } from '../hooks/usePerpsTradingCampaignEndedOutcomeToast'; /** * CampaignsView displays all campaigns organized by status: @@ -31,9 +32,10 @@ import { strings } from '../../../../../locales/i18n'; const CampaignsView: React.FC = () => { const tw = useTailwind(); const navigation = useNavigation(); - useOndoOutcomeToast(); const { categorizedCampaigns, isLoading, hasError, fetchCampaigns } = useRewardCampaigns(); + useOndoOutcomeToast(); + usePerpsTradingCampaignEndedOutcomeToast(); useTrackRewardsPageView({ page_type: 'campaigns_overview' }); diff --git a/app/components/UI/Rewards/Views/OndoCampaignStatsView.test.tsx b/app/components/UI/Rewards/Views/OndoCampaignStatsView.test.tsx index 0a84edb2cbd..d1d1915730b 100644 --- a/app/components/UI/Rewards/Views/OndoCampaignStatsView.test.tsx +++ b/app/components/UI/Rewards/Views/OndoCampaignStatsView.test.tsx @@ -445,7 +445,9 @@ describe('OndoCampaignStatsView', () => { hasError: false, }); const { getByText } = render(); - const title = getByText('rewards.ondo_outcome_banner.winner_pending.title'); + const title = getByText( + 'rewards.campaign_outcome_banner.winner_pending.title', + ); fireEvent.press(title); expect(mockNavigate).toHaveBeenCalledWith( Routes.REWARDS_ONDO_CAMPAIGN_WINNING_VIEW, @@ -475,7 +477,7 @@ describe('OndoCampaignStatsView', () => { }); const { queryByText } = render(); expect( - queryByText('rewards.ondo_outcome_banner.winner_pending.title'), + queryByText('rewards.campaign_outcome_banner.winner_pending.title'), ).toBeNull(); }); @@ -1024,7 +1026,9 @@ describe('OndoCampaignStatsView', () => { hasError: false, }); const { getByText } = render(); - const title = getByText('rewards.ondo_outcome_banner.winner_pending.title'); + const title = getByText( + 'rewards.campaign_outcome_banner.winner_pending.title', + ); fireEvent.press(title); expect(mockNavigate).toHaveBeenCalledWith( Routes.REWARDS_ONDO_CAMPAIGN_WINNING_VIEW, diff --git a/app/components/UI/Rewards/Views/OndoCampaignStatsView.tsx b/app/components/UI/Rewards/Views/OndoCampaignStatsView.tsx index b17b28efa50..520d10dfafd 100644 --- a/app/components/UI/Rewards/Views/OndoCampaignStatsView.tsx +++ b/app/components/UI/Rewards/Views/OndoCampaignStatsView.tsx @@ -14,7 +14,7 @@ import { TextColor, TextVariant, } from '@metamask/design-system-react-native'; -import { OndoGmCampaignOutcomeBanner } from '../components/Campaigns/OndoCampaignOutcomeBanners'; +import { CampaignOutcomeBanner } from '../components/Campaigns/CampaignOutcomeBanners'; import { useTailwind } from '@metamask/design-system-twrnc-preset'; import { SafeAreaView } from 'react-native-safe-area-context'; import ErrorBoundary from '../../../Views/ErrorBoundary'; @@ -284,7 +284,7 @@ const OndoCampaignStatsView: React.FC = () => { {/* ── Outcome banner (campaign ended) ── */} {isCampaignComplete && participantOutcome && ( - ({ - __esModule: true, - default: 1, -})); - -const mockGoBack = jest.fn(); - -const mockNavigate = jest.fn(); - -jest.mock('@react-navigation/native', () => ({ - useNavigation: () => ({ goBack: mockGoBack, navigate: mockNavigate }), - useRoute: () => ({ - params: { campaignId: 'campaign-ondo-1', campaignName: 'Ondo Campaign' }, - }), -})); - -jest.mock('@metamask/design-system-twrnc-preset', () => { - const tw = (...args: unknown[]) => args; - tw.style = (...args: unknown[]) => args; - return { useTailwind: () => tw }; -}); - -jest.mock('react-native-safe-area-context', () => { - const actual = jest.requireActual('react-native-safe-area-context'); - return { - ...actual, - useSafeAreaInsets: () => ({ top: 44, bottom: 34, left: 0, right: 0 }), - }; -}); - -jest.mock('../hooks/useOndoCampaignParticipantOutcome', () => ({ - useOndoCampaignParticipantOutcome: jest.fn(), -})); - -const mockUseOndoCampaignParticipantOutcome = - useOndoCampaignParticipantOutcome as jest.MockedFunction< - typeof useOndoCampaignParticipantOutcome - >; - -jest.mock('../../../Views/ErrorBoundary', () => { +jest.mock('./CampaignWinningView', () => { const ReactActual = jest.requireActual('react'); const { View } = jest.requireActual('react-native'); return { __esModule: true, - default: ({ children }: { children: React.ReactNode }) => - ReactActual.createElement(View, null, children), + default: jest.fn(({ testID }: { testID: string }) => + ReactActual.createElement(View, { testID }), + ), }; }); -jest.mock('../hooks/useTrackRewardsPageView', () => ({ - __esModule: true, - default: jest.fn(), -})); - -jest.mock('../../../../core/Analytics', () => ({ - MetaMetricsEvents: { - REWARDS_PAGE_BUTTON_CLICKED: 'REWARDS_PAGE_BUTTON_CLICKED', - }, -})); - -jest.mock('../utils', () => ({ - RewardsMetricsButtons: { - COPY_REFERRAL_CODE: 'copy_referral_code', - }, -})); - -const mockTrackEvent = jest.fn(); -const mockBuild = jest.fn(() => ({})); -jest.mock('../../../hooks/useAnalytics/useAnalytics', () => ({ - useAnalytics: () => ({ - trackEvent: mockTrackEvent, - createEventBuilder: () => ({ - addProperties: () => ({ build: mockBuild }), - }), - }), +jest.mock('../hooks/useOndoCampaignParticipantOutcome', () => ({ + useOndoCampaignParticipantOutcome: jest.fn(), })); -const mockPosition = { - projectedTier: 'MID', - rank: 3, - totalInTier: 100, - rateOfReturn: 0.2823, - currentUsdValue: 2000, - totalUsdDeposited: 1000, - netDeposit: 900, - qualifiedDays: 10, - qualified: true, - neighbors: [], - computedAt: '2024-01-01T00:00:00.000Z', -}; - jest.mock('../hooks/useGetOndoLeaderboardPosition', () => ({ useGetOndoLeaderboardPosition: jest.fn(), })); -const mockUseGetOndoLeaderboardPosition = - useGetOndoLeaderboardPosition as jest.MockedFunction< - typeof useGetOndoLeaderboardPosition - >; +jest.mock('@react-navigation/native', () => ({ + useNavigation: () => ({ goBack: jest.fn(), navigate: jest.fn() }), + useRoute: () => ({ + params: { campaignId: 'campaign-ondo-1', campaignName: 'Ondo Campaign' }, + }), +})); -jest.mock('../components/ReferralDetails/CopyableField', () => { - const ReactActual = jest.requireActual('react'); - const { View, Text, Pressable } = jest.requireActual('react-native'); - return { - __esModule: true, - default: ({ - label, - value, - onCopy, - }: { - label: string; - value?: string | null; - onCopy?: () => void; - }) => - ReactActual.createElement( - View, - { testID: 'copyable-field' }, - ReactActual.createElement(Text, null, label), - ReactActual.createElement( - Text, - { testID: 'copyable-value' }, - value ?? '', - ), - ReactActual.createElement(Pressable, { - testID: 'copyable-trigger', - onPress: onCopy, - }), - ), - }; +jest.mock('@metamask/design-system-twrnc-preset', () => { + const tw = (...args: unknown[]) => args; + tw.style = (...args: unknown[]) => args; + return { useTailwind: () => tw }; }); -jest.mock('../../../../../locales/i18n', () => ({ - strings: jest.fn( - (key: string, params?: { place?: string; code?: string }) => { - const map: Record = { - 'rewards.ondo_campaign_winning.you_won': 'You won', - 'rewards.ondo_campaign_winning.email_instructions': - 'Email ondocampaign@consensys.net with your code to claim your prize.', - 'rewards.ondo_campaign_winning.open_mail': 'Open mail', - 'rewards.ondo_campaign_winning.skip_for_now': 'Skip for now', - 'rewards.ondo_campaign_winning.mail_subject': - 'Ondo campaign prize claim', - 'rewards.ondo_campaign_winning.mail_body': `My winning code: ${params?.code ?? ''}`, - 'rewards.ondo_campaign_winning.winning_code': 'Winning code', - 'rewards.ondo_campaign_winning.close_a11y': 'Close', - 'rewards.ondo_campaign_winning.error_title': - 'Could not load your winning code', - 'rewards.ondo_campaign_winning.error_description': - 'Something went wrong while fetching your code. Please try again later or contact support.', - 'rewards.ondo_campaign_winning.error_retry': 'Try again', - }; - if (key === 'rewards.ondo_campaign_winning.rank_label' && params?.place) { - return `${params.place} place`; - } - return map[key] ?? key; - }, - ), -})); +const mockUseOutcome = useOndoCampaignParticipantOutcome as jest.MockedFunction< + typeof useOndoCampaignParticipantOutcome +>; +const mockUsePosition = useGetOndoLeaderboardPosition as jest.MockedFunction< + typeof useGetOndoLeaderboardPosition +>; +const mockCampaignWinningView = CampaignWinningView as jest.MockedFunction< + typeof CampaignWinningView +>; describe('OndoCampaignWinningView', () => { beforeEach(() => { jest.clearAllMocks(); - mockUseGetOndoLeaderboardPosition.mockReturnValue({ - position: mockPosition, - isLoading: false, - hasError: false, - hasFetched: true, - refetch: jest.fn(), - }); - mockUseOndoCampaignParticipantOutcome.mockReturnValue({ + mockUseOutcome.mockReturnValue({ outcome: { subscriptionId: 'sub-1', outcomeStatus: 'pending', - winnerVerificationCode: 'LVL346', + winnerVerificationCode: 'ONDO-WIN-99', }, isLoading: false, hasError: false, }); + mockUsePosition.mockReturnValue({ + position: null, + isLoading: false, + hasError: false, + hasFetched: true, + refetch: jest.fn(), + }); }); - it('renders the main container', () => { + it('renders the container with the Ondo testID', () => { const { getByTestId } = render(); expect( getByTestId(ONDO_CAMPAIGN_WINNING_VIEW_TEST_IDS.CONTAINER), ).toBeTruthy(); }); - it('shows you won, rank place, and rate from leaderboard position', () => { - const { getByText } = render(); - expect(getByText('You won')).toBeTruthy(); - expect(getByText('3rd place')).toBeTruthy(); - expect(getByText('+28.23%')).toBeTruthy(); - }); - - it('calls goBack when Skip for now is pressed', () => { - const { getByText } = render(); - fireEvent.press(getByText('Skip for now')); - expect(mockGoBack).toHaveBeenCalledTimes(1); - }); - - it('calls goBack when close is pressed', () => { - const { getByLabelText } = render(); - fireEvent.press(getByLabelText('Close')); - expect(mockGoBack).toHaveBeenCalledTimes(1); - }); - - it('copies referral code and tracks analytics when copy is triggered', () => { - const setStringSpy = jest.spyOn(Clipboard, 'setString'); - const { getByTestId } = render(); - fireEvent.press(getByTestId('copyable-trigger')); - expect(setStringSpy).toHaveBeenCalledWith('LVL346'); - expect(mockTrackEvent).toHaveBeenCalled(); - }); - - it('opens mailto when Open mail is pressed', async () => { - const openSpy = jest.spyOn(Linking, 'openURL').mockResolvedValue(undefined); - const { getByText } = render(); - fireEvent.press(getByText('Open mail')); - expect(openSpy).toHaveBeenCalled(); - const url = openSpy.mock.calls[0][0] as string; - expect(url).toContain('mailto:ondocampaign@consensys.net'); - expect(url).toContain(encodeURIComponent('LVL346')); - openSpy.mockRestore(); - }); - - describe('auto-redirect when user is not a winner', () => { - it('navigates to details view when outcome loaded but has no winner code', () => { - mockUseOndoCampaignParticipantOutcome.mockReturnValue({ - outcome: { - subscriptionId: 'sub-1', - outcomeStatus: 'pending', - winnerVerificationCode: null, - }, - isLoading: false, - hasError: false, - }); - render(); - expect(mockNavigate).toHaveBeenCalledWith( - Routes.REWARDS_ONDO_CAMPAIGN_DETAILS_VIEW, - { campaignId: 'campaign-ondo-1' }, - ); - }); - - it('does not navigate while outcome is still loading', () => { - mockUseOndoCampaignParticipantOutcome.mockReturnValue({ - outcome: null, - isLoading: true, - hasError: false, - }); - render(); - expect(mockNavigate).not.toHaveBeenCalled(); - }); - - it('does not navigate when outcome is null after load', () => { - mockUseOndoCampaignParticipantOutcome.mockReturnValue({ - outcome: null, - isLoading: false, - hasError: false, - }); - render(); - expect(mockNavigate).not.toHaveBeenCalled(); - }); - }); - - describe('loading states', () => { - it('shows CopyableField once winning code has loaded', () => { - const { getByTestId } = render(); - expect(getByTestId('copyable-field')).toBeTruthy(); - }); - - it('shows the primary CTA in loading state while outcome is loading', () => { - mockUseOndoCampaignParticipantOutcome.mockReturnValue({ - outcome: null, - isLoading: true, - hasError: false, - }); - const openSpy = jest - .spyOn(Linking, 'openURL') - .mockResolvedValue(undefined); - const { getByText } = render(); - fireEvent.press(getByText('Open mail')); - expect(openSpy).not.toHaveBeenCalled(); - openSpy.mockRestore(); - }); - - it('does not show the primary CTA in loading state once code has loaded', () => { - const openSpy = jest - .spyOn(Linking, 'openURL') - .mockResolvedValue(undefined); - const { getByText } = render(); - fireEvent.press(getByText('Open mail')); - expect(openSpy).toHaveBeenCalledTimes(1); - openSpy.mockRestore(); - }); - - it('hides rank and rate text while position is loading', () => { - mockUseGetOndoLeaderboardPosition.mockReturnValue({ - position: null, - isLoading: true, - hasError: false, - hasFetched: false, - refetch: jest.fn(), - }); - const { queryByText } = render(); - expect(queryByText('3rd place')).toBeNull(); - expect(queryByText('+28.23%')).toBeNull(); - }); - }); - - describe('error states', () => { - it('hides the rank/rate section entirely when position fails to load', () => { - mockUseGetOndoLeaderboardPosition.mockReturnValue({ - position: null, + it('passes correct Ondo-specific props to CampaignWinningView', () => { + render(); + expect(mockCampaignWinningView).toHaveBeenCalledWith( + expect.objectContaining({ + testID: ONDO_CAMPAIGN_WINNING_VIEW_TEST_IDS.CONTAINER, + prizeEmail: 'ondocampaign@consensys.net', + campaignName: 'Ondo Campaign', + campaignId: 'campaign-ondo-1', + analyticsPageType: 'ondo_campaign_winning', + winningCode: 'ONDO-WIN-99', + hasOutcomeLoaded: true, isLoading: false, - hasError: true, - hasFetched: true, - refetch: jest.fn(), - }); - const { queryByText } = render(); - expect(queryByText('3rd place')).toBeNull(); - expect(queryByText('+28.23%')).toBeNull(); - }); - - it('hides the rank/rate section when position is null and not loading', () => { - mockUseGetOndoLeaderboardPosition.mockReturnValue({ - position: null, - isLoading: false, - hasError: false, - hasFetched: true, - refetch: jest.fn(), - }); - const { queryByText } = render(); - expect(queryByText('3rd place')).toBeNull(); - expect(queryByText('+28.23%')).toBeNull(); - }); + rankDisplay: null, + resultDisplay: null, + isRankLoading: false, + isResultLoading: false, + fallbackRoute: { + route: Routes.REWARDS_ONDO_CAMPAIGN_DETAILS_VIEW, + params: { campaignId: 'campaign-ondo-1' }, + }, + }), + {}, + ); }); - it('does not throw when mailto openURL rejects', async () => { - const openSpy = jest - .spyOn(Linking, 'openURL') - .mockRejectedValue(new Error('no mail app')); - const { getByText } = render(); - await act(async () => { - fireEvent.press(getByText('Open mail')); + it('passes winningCode as null when outcome has no code', () => { + mockUseOutcome.mockReturnValue({ + outcome: { + subscriptionId: 'sub-1', + outcomeStatus: 'finalized', + winnerVerificationCode: null, + }, + isLoading: false, + hasError: false, }); - expect(openSpy).toHaveBeenCalled(); - openSpy.mockRestore(); + render(); + expect(mockCampaignWinningView).toHaveBeenCalledWith( + expect.objectContaining({ + winningCode: null, + hasOutcomeLoaded: true, + }), + {}, + ); }); - describe('mailto URL construction', () => { - it('appends the winning code to the mail subject', async () => { - const openSpy = jest - .spyOn(Linking, 'openURL') - .mockResolvedValue(undefined); - const { getByText } = render(); - fireEvent.press(getByText('Open mail')); - const url = openSpy.mock.calls[0][0] as string; - expect(url).toContain( - encodeURIComponent('Ondo campaign prize claim - LVL346'), - ); - openSpy.mockRestore(); - }); - - it('uses base subject without code when winningCode is null', async () => { - mockUseOndoCampaignParticipantOutcome.mockReturnValue({ - outcome: { - subscriptionId: 'sub-1', - outcomeStatus: 'pending', - winnerVerificationCode: null, - }, - isLoading: false, - hasError: false, - }); - const openSpy = jest - .spyOn(Linking, 'openURL') - .mockResolvedValue(undefined); - const { getByText } = render(); - fireEvent.press(getByText('Open mail')); - const url = openSpy.mock.calls[0][0] as string; - expect(url).toContain(encodeURIComponent('Ondo campaign prize claim')); - expect(url).not.toContain(' - '); - openSpy.mockRestore(); + it('does not mark outcome as loaded until the outcome exists', () => { + mockUseOutcome.mockReturnValue({ + outcome: null, + isLoading: false, + hasError: false, }); + render(); + expect(mockCampaignWinningView).toHaveBeenCalledWith( + expect.objectContaining({ + winningCode: null, + hasOutcomeLoaded: false, + }), + {}, + ); }); - it('does not copy to clipboard when winning code is null', () => { - mockUseOndoCampaignParticipantOutcome.mockReturnValue({ + it('passes rank and result display when position is available', () => { + mockUseOutcome.mockReturnValue({ outcome: { subscriptionId: 'sub-1', outcomeStatus: 'pending', - winnerVerificationCode: null, + winnerVerificationCode: 'ONDO-WIN-99', + tierRank: 3, }, isLoading: false, hasError: false, }); - const setStringSpy = jest.spyOn(Clipboard, 'setString'); - const { getByTestId } = render(); - fireEvent.press(getByTestId('copyable-trigger')); - expect(setStringSpy).not.toHaveBeenCalled(); + mockUsePosition.mockReturnValue({ + position: { rank: 9, rateOfReturn: 0.1234 } as never, + isLoading: false, + hasError: false, + hasFetched: true, + refetch: jest.fn(), + }); + + render(); + expect(mockCampaignWinningView).toHaveBeenCalledWith( + expect.objectContaining({ + rankDisplay: '3rd', + resultDisplay: '+12.34%', + isRankLoading: false, + isResultLoading: false, + }), + {}, + ); }); }); diff --git a/app/components/UI/Rewards/Views/OndoCampaignWinningView.tsx b/app/components/UI/Rewards/Views/OndoCampaignWinningView.tsx index 59b3434babe..7e40d530493 100644 --- a/app/components/UI/Rewards/Views/OndoCampaignWinningView.tsx +++ b/app/components/UI/Rewards/Views/OndoCampaignWinningView.tsx @@ -1,45 +1,13 @@ -import React, { useCallback, useEffect, useMemo } from 'react'; -import { Image, Linking, ScrollView, StyleSheet } from 'react-native'; -import Clipboard from '@react-native-clipboard/clipboard'; -import { useNavigation, useRoute, RouteProp } from '@react-navigation/native'; -import { - SafeAreaView, - useSafeAreaInsets, -} from 'react-native-safe-area-context'; -import { useTailwind } from '@metamask/design-system-twrnc-preset'; -import { - Box, - BoxFlexDirection, - Button, - ButtonSize, - ButtonVariant, - ButtonIcon, - ButtonIconSize, - IconName, - Skeleton, - Text, - TextColor, - TextVariant, -} from '@metamask/design-system-react-native'; -import ErrorBoundary from '../../../Views/ErrorBoundary'; -import useTrackRewardsPageView from '../hooks/useTrackRewardsPageView'; +import React, { useMemo } from 'react'; +import { useRoute, RouteProp } from '@react-navigation/native'; import { useOndoCampaignParticipantOutcome } from '../hooks/useOndoCampaignParticipantOutcome'; -import Routes from '../../../../constants/navigation/Routes'; -import { strings } from '../../../../../locales/i18n'; -import CopyableField from '../components/ReferralDetails/CopyableField'; import { formatOrdinalRank, formatPercentChange } from '../utils/formatUtils'; -import { RewardsMetricsButtons } from '../utils'; -import { useAnalytics } from '../../../hooks/useAnalytics/useAnalytics'; -import { MetaMetricsEvents } from '../../../../core/Analytics'; import { useGetOndoLeaderboardPosition } from '../hooks/useGetOndoLeaderboardPosition'; -import campaignWinningHero from '../../../../images/rewards/campaign_winning.png'; +import CampaignWinningView from './CampaignWinningView'; +import Routes from '../../../../constants/navigation/Routes'; const PRIZE_EMAIL = 'ondocampaign@consensys.net'; -const styles = StyleSheet.create({ - heroBox: { aspectRatio: 1 }, -}); - // ParamListBase requires an index signature, which interfaces don't support // eslint-disable-next-line @typescript-eslint/consistent-type-definitions type OndoCampaignWinningRouteParams = { @@ -51,15 +19,11 @@ export const ONDO_CAMPAIGN_WINNING_VIEW_TEST_IDS = { } as const; const OndoCampaignWinningView: React.FC = () => { - const tw = useTailwind(); - const insets = useSafeAreaInsets(); - const navigation = useNavigation(); - const { trackEvent, createEventBuilder } = useAnalytics(); const route = useRoute< RouteProp >(); - const { campaignId } = route.params; + const { campaignId, campaignName = '' } = route.params; const { position, isLoading: positionLoading } = useGetOndoLeaderboardPosition(campaignId); @@ -68,186 +32,41 @@ const OndoCampaignWinningView: React.FC = () => { useOndoCampaignParticipantOutcome(campaignId); const winningCode = outcome?.winnerVerificationCode ?? null; - useEffect(() => { - if (!isOutcomeLoading && outcome && !winningCode) { - navigation.navigate(Routes.REWARDS_ONDO_CAMPAIGN_DETAILS_VIEW, { - campaignId, - }); - } - }, [isOutcomeLoading, outcome, winningCode, campaignId, navigation]); - - useTrackRewardsPageView({ - page_type: 'ondo_campaign_winning', - campaign_id: campaignId, - }); - - const onDismiss = () => navigation.goBack(); - - const handleCopyWinningCode = useCallback(() => { - if (winningCode) { - Clipboard.setString(winningCode); - trackEvent( - createEventBuilder(MetaMetricsEvents.REWARDS_PAGE_BUTTON_CLICKED) - .addProperties({ - button_type: RewardsMetricsButtons.COPY_REFERRAL_CODE, - }) - .build(), - ); - } - }, [winningCode, trackEvent, createEventBuilder]); - - const handleOpenMail = useCallback(async () => { - const baseSubject = strings('rewards.ondo_campaign_winning.mail_subject'); - const subject = winningCode - ? `${baseSubject} - ${winningCode}` - : baseSubject; - const body = strings('rewards.ondo_campaign_winning.mail_body', { - code: winningCode || '—', - }); - const url = `mailto:${PRIZE_EMAIL}?subject=${encodeURIComponent(subject)}&body=${encodeURIComponent(body)}`; - try { - await Linking.openURL(url); - } catch { - // no-op: device may not have a mail handler - } - }, [winningCode]); - const rankDisplay = useMemo(() => { - if (!position) return null; - return strings('rewards.ondo_campaign_winning.rank_label', { - place: formatOrdinalRank(position.rank), - }); - }, [position]); + if (!outcome?.tierRank) return null; + return formatOrdinalRank(outcome.tierRank); + }, [outcome]); - const rateDisplay = useMemo(() => { + const resultDisplay = useMemo(() => { if (!position) return null; return formatPercentChange(position.rateOfReturn); }, [position]); - return ( - - - - - - - - - - - - - {strings('rewards.ondo_campaign_winning.you_won')} - - - {(positionLoading || position) && ( - - {rankDisplay !== null ? ( - - {rankDisplay} - - ) : ( - - )} - - {rateDisplay !== null ? ( - - {rateDisplay} - - ) : ( - - )} - - )} - - - {strings('rewards.ondo_campaign_winning.email_instructions')} - - - - - - - - - + const fallbackRoute = useMemo( + () => ({ + route: Routes.REWARDS_ONDO_CAMPAIGN_DETAILS_VIEW, + params: { campaignId }, + }), + [campaignId], + ); - - - - - + return ( + ); }; diff --git a/app/components/UI/Rewards/Views/PerpsTradingCampaignDetailsView.test.tsx b/app/components/UI/Rewards/Views/PerpsTradingCampaignDetailsView.test.tsx index 54fe6b66bee..5ab6a0e70aa 100644 --- a/app/components/UI/Rewards/Views/PerpsTradingCampaignDetailsView.test.tsx +++ b/app/components/UI/Rewards/Views/PerpsTradingCampaignDetailsView.test.tsx @@ -2,18 +2,21 @@ import React from 'react'; import { render, fireEvent } from '@testing-library/react-native'; import PerpsTradingCampaignDetailsView, { PERPS_CAMPAIGN_DETAILS_TEST_IDS, + resetPerpsTradingCampaignDetailsSessionAutoNavigationForTests, } from './PerpsTradingCampaignDetailsView'; import { type CampaignDto, CampaignType, type PerpsTradingCampaignLeaderboardEntry, type PerpsTradingCampaignLeaderboardPositionDto, + type PerpsTradingCampaignParticipantOutcomeDto, } from '../../../../core/Engine/controllers/rewards-controller/types'; import { useRewardCampaigns } from '../hooks/useRewardCampaigns'; import { useGetCampaignParticipantStatus } from '../hooks/useGetCampaignParticipantStatus'; import { useGetPerpsTradingCampaignLeaderboard } from '../hooks/useGetPerpsTradingCampaignLeaderboard'; import { useGetPerpsTradingCampaignLeaderboardPosition } from '../hooks/useGetPerpsTradingCampaignLeaderboardPosition'; import { useGetPerpsTradingCampaignVolume } from '../hooks/useGetPerpsTradingCampaignVolume'; +import { usePerpsTradingCampaignParticipantOutcome } from '../hooks/usePerpsTradingCampaignParticipantOutcome'; import Routes from '../../../../constants/navigation/Routes'; const mockGoBack = jest.fn(); @@ -24,6 +27,7 @@ const mockRouteState: { params: { campaignId?: string } } = { }; jest.mock('@react-navigation/native', () => ({ + useFocusEffect: (callback: () => void) => callback(), useNavigation: () => ({ goBack: mockGoBack, navigate: mockNavigate, @@ -135,15 +139,61 @@ jest.mock('../components/Campaigns/CampaignHowItWorks', () => { }; }); +jest.mock('../components/Campaigns/CampaignOutcomeBanners', () => { + const ReactActual = jest.requireActual('react'); + const { Pressable, Text } = jest.requireActual('react-native'); + return { + __esModule: true, + CampaignOutcomeBanner: ({ + outcomeStatus, + winnerVerificationCode, + onWinnerPress, + }: { + outcomeStatus: string; + winnerVerificationCode: string | null | undefined; + onWinnerPress: () => void; + }) => + ReactActual.createElement( + Pressable, + { + testID: `campaign-outcome-banner-${outcomeStatus}-${winnerVerificationCode ?? 'null'}`, + onPress: onWinnerPress, + }, + ReactActual.createElement(Text, null, 'Campaign outcome'), + ), + }; +}); + jest.mock('../components/Campaigns/PerpsCampaignStatsSummary', () => { const ReactActual = jest.requireActual('react'); - const { View } = jest.requireActual('react-native'); + const { Pressable, Text, View } = jest.requireActual('react-native'); return { __esModule: true, - default: () => - ReactActual.createElement(View, { - testID: 'perps-campaign-stats-summary-container', - }), + default: ({ + outcomeStatus, + winnerVerificationCode, + onWinnerPress, + }: { + outcomeStatus?: string; + winnerVerificationCode?: string | null; + onWinnerPress?: () => void; + }) => + ReactActual.createElement( + View, + { + testID: 'perps-campaign-stats-summary-container', + }, + outcomeStatus && + onWinnerPress && + ReactActual.createElement( + Pressable, + { + testID: `campaign-outcome-banner-${outcomeStatus}-${winnerVerificationCode ?? 'null'}`, + onPress: onWinnerPress, + }, + ReactActual.createElement(Text, null, 'Campaign outcome'), + ), + ), }; }); @@ -157,6 +207,18 @@ jest.mock('../components/Campaigns/PerpsTradingCampaignPrizePool', () => { }; }); +jest.mock('../components/Campaigns/PerpsTradingCampaignEndedStats', () => { + const ReactActual = jest.requireActual('react'); + const { View } = jest.requireActual('react-native'); + return { + __esModule: true, + default: () => + ReactActual.createElement(View, { + testID: 'perps-campaign-ended-stats', + }), + }; +}); + jest.mock('../components/Campaigns/PerpsTradingCampaignLeaderboard', () => { const ReactActual = jest.requireActual('react'); const { View } = jest.requireActual('react-native'); @@ -247,6 +309,12 @@ const mockUseGetPerpsTradingCampaignVolume = typeof useGetPerpsTradingCampaignVolume >; +jest.mock('../hooks/usePerpsTradingCampaignParticipantOutcome'); +const mockUsePerpsTradingCampaignParticipantOutcome = + usePerpsTradingCampaignParticipantOutcome as jest.MockedFunction< + typeof usePerpsTradingCampaignParticipantOutcome + >; + import { useSelector } from 'react-redux'; import { selectReferralCode } from '../../../../reducers/rewards/selectors'; @@ -316,7 +384,9 @@ function setupHooks( hasCampaignsError?: boolean; participant?: { optedIn: boolean }; position?: { rank: number; neighbors: unknown[] } | null; + isPositionLoading?: boolean; totalParticipants?: number; + outcome?: PerpsTradingCampaignParticipantOutcomeDto | null; } = {}, ) { const { @@ -325,7 +395,9 @@ function setupHooks( hasCampaignsError = false, participant = { optedIn: false }, position = null, + isPositionLoading = false, totalParticipants: totalParticipantsOverride, + outcome = null, } = overrides; mockUseRewardCampaigns.mockReturnValue({ @@ -361,7 +433,7 @@ function setupHooks( mockUseGetPerpsTradingCampaignLeaderboardPosition.mockReturnValue({ position: toMockLeaderboardPosition(position), - isLoading: false, + isLoading: isPositionLoading, hasError: false, hasFetched: true, refetch: jest.fn(), @@ -370,6 +442,12 @@ function setupHooks( mockUseGetPerpsTradingCampaignVolume.mockReturnValue({ ...defaultVolumeHook, } as ReturnType); + + mockUsePerpsTradingCampaignParticipantOutcome.mockReturnValue({ + outcome, + isLoading: false, + hasError: false, + } as ReturnType); } jest.mock('../../../../../locales/i18n', () => ({ @@ -398,6 +476,7 @@ describe('PerpsTradingCampaignDetailsView', () => { jest.useFakeTimers(); jest.setSystemTime(new Date('2025-08-15T12:00:00.000Z')); jest.clearAllMocks(); + resetPerpsTradingCampaignDetailsSessionAutoNavigationForTests(); mockRouteState.params = { campaignId: 'perps-campaign-1' }; mockUseSelector.mockImplementation((selector) => { if (selector === selectReferralCode) { @@ -434,10 +513,10 @@ describe('PerpsTradingCampaignDetailsView', () => { expect(mockFetchCampaigns).toHaveBeenCalledTimes(1); }); - it('renders header, campaign status, prize pool, leaderboard, and CTA for active campaign', () => { - const { getByTestId, getByText } = render( - , - ); + it('renders header, campaign status, prize pool, leaderboard, and CTA for active opted-in campaign', () => { + setupHooks({ participant: { optedIn: true } }); + + const { getByTestId } = render(); expect( getByTestId(PERPS_CAMPAIGN_DETAILS_TEST_IDS.CONTAINER), @@ -449,7 +528,12 @@ describe('PerpsTradingCampaignDetailsView', () => { expect(getByTestId('perps-trading-cta')).toBeDefined(); }); - it('hides How it works when the user has a leaderboard position', () => { + it('shows the prize pool section for active non-opted-in users', () => { + const { getByTestId } = render(); + expect(getByTestId('perps-prize-pool')).toBeDefined(); + }); + + it('hides How it works when the user is opted in and has a leaderboard position', () => { setupHooks({ campaigns: [ buildPerpsCampaign({ @@ -470,7 +554,50 @@ describe('PerpsTradingCampaignDetailsView', () => { expect(queryByTestId('campaign-how-it-works')).toBeNull(); }); - it('shows How it works when active, user has no leaderboard position, and details include howItWorks', () => { + it('shows How it works when opted in, no position, and position not loading', () => { + setupHooks({ + campaigns: [ + buildPerpsCampaign({ + details: { + howItWorks: { + title: 'How it works', + description: 'Test description', + steps: [], + }, + }, + }), + ], + participant: { optedIn: true }, + position: null, + }); + + const { getByTestId } = render(); + expect(getByTestId('campaign-how-it-works')).toBeDefined(); + }); + + it('hides How it works while the leaderboard position is still loading for an opted-in user', () => { + setupHooks({ + campaigns: [ + buildPerpsCampaign({ + details: { + howItWorks: { + title: 'How it works', + description: 'Test description', + steps: [], + }, + }, + }), + ], + participant: { optedIn: true }, + position: null, + isPositionLoading: true, + }); + + const { queryByTestId } = render(); + expect(queryByTestId('campaign-how-it-works')).toBeNull(); + }); + + it('shows How it works when active, user is not opted in, and details include howItWorks', () => { setupHooks({ campaigns: [ buildPerpsCampaign({ @@ -532,7 +659,7 @@ describe('PerpsTradingCampaignDetailsView', () => { ); }); - it('complete campaign shows leaderboard without stats row and hides CTA', () => { + it('complete campaign for non-opted-in user shows leaderboard, prize pool, and ended stats and hides CTA', () => { setupHooks({ campaigns: [ buildPerpsCampaign({ @@ -548,10 +675,149 @@ describe('PerpsTradingCampaignDetailsView', () => { expect(getByTestId('perps-leaderboard')).toBeDefined(); expect(queryByTestId('perps-campaign-stats-summary-container')).toBeNull(); - expect(queryByTestId('perps-prize-pool')).toBeNull(); + expect(getByTestId('perps-prize-pool')).toBeDefined(); + expect(getByTestId('perps-campaign-ended-stats')).toBeDefined(); expect(queryByTestId('perps-trading-cta')).toBeNull(); }); + it('complete campaign for opted-in user (no leaderboard position) shows ended stats and prize pool', () => { + setupHooks({ + campaigns: [ + buildPerpsCampaign({ + startDate: '2024-01-01T00:00:00.000Z', + endDate: '2025-01-01T00:00:00.000Z', + }), + ], + participant: { optedIn: true }, + position: null, + }); + + const { getByTestId, queryByTestId } = render( + , + ); + + expect(getByTestId('perps-campaign-ended-stats')).toBeDefined(); + expect(getByTestId('perps-prize-pool')).toBeDefined(); + expect(queryByTestId('perps-campaign-stats-summary-container')).toBeNull(); + }); + + it('shows outcome banner for completed opted-in participants and navigates winners to winning view', () => { + setupHooks({ + campaigns: [ + buildPerpsCampaign({ + startDate: '2024-01-01T00:00:00.000Z', + endDate: '2025-01-01T00:00:00.000Z', + }), + ], + participant: { optedIn: true }, + position: { rank: 3, neighbors: [] }, + outcome: { + subscriptionId: 'subscription-id', + outcomeStatus: 'pending', + winnerVerificationCode: 'PERPS-WINNER-123', + rank: 3, + }, + }); + + const { getByTestId } = render(); + + fireEvent.press( + getByTestId('campaign-outcome-banner-pending-PERPS-WINNER-123'), + ); + expect(mockNavigate).toHaveBeenCalledWith( + Routes.REWARDS_PERPS_TRADING_CAMPAIGN_WINNING_VIEW, + { + campaignId: 'perps-campaign-1', + campaignName: 'Perps Trading', + }, + ); + }); + + it('auto-navigates once to winning view for a completed pending winner outcome', () => { + setupHooks({ + campaigns: [ + buildPerpsCampaign({ + startDate: '2024-01-01T00:00:00.000Z', + endDate: '2025-01-01T00:00:00.000Z', + }), + ], + participant: { optedIn: true }, + position: { rank: 3, neighbors: [] }, + outcome: { + subscriptionId: 'subscription-id', + outcomeStatus: 'pending', + winnerVerificationCode: 'PERPS-WINNER-123', + rank: 3, + }, + }); + + const { rerender } = render(); + + expect(mockNavigate).toHaveBeenCalledWith( + Routes.REWARDS_PERPS_TRADING_CAMPAIGN_WINNING_VIEW, + { + campaignId: 'perps-campaign-1', + campaignName: 'Perps Trading', + }, + ); + + mockNavigate.mockClear(); + rerender(); + expect(mockNavigate).not.toHaveBeenCalled(); + }); + + it('does not auto-navigate for finalized outcomes', () => { + setupHooks({ + campaigns: [ + buildPerpsCampaign({ + startDate: '2024-01-01T00:00:00.000Z', + endDate: '2025-01-01T00:00:00.000Z', + }), + ], + participant: { optedIn: true }, + position: { rank: 3, neighbors: [] }, + outcome: { + subscriptionId: 'subscription-id', + outcomeStatus: 'finalized', + winnerVerificationCode: 'PERPS-WINNER-123', + rank: 3, + }, + }); + + render(); + + expect(mockNavigate).not.toHaveBeenCalledWith( + Routes.REWARDS_PERPS_TRADING_CAMPAIGN_WINNING_VIEW, + expect.any(Object), + ); + }); + + it('shows outcome banner inside the ended stats section for opted-in users with no leaderboard position', () => { + setupHooks({ + campaigns: [ + buildPerpsCampaign({ + startDate: '2024-01-01T00:00:00.000Z', + endDate: '2025-01-01T00:00:00.000Z', + }), + ], + participant: { optedIn: true }, + position: null, + outcome: { + subscriptionId: 'subscription-id', + outcomeStatus: 'finalized', + winnerVerificationCode: null, + }, + }); + + const { getByTestId, queryByTestId } = render( + , + ); + + expect(queryByTestId('perps-campaign-stats-summary-container')).toBeNull(); + expect(getByTestId('perps-campaign-ended-stats')).toBeDefined(); + expect(getByTestId('campaign-outcome-banner-finalized-null')).toBeDefined(); + }); + it('displays total participant count when the leaderboard reports participants', () => { setupHooks({ totalParticipants: 1500 }); diff --git a/app/components/UI/Rewards/Views/PerpsTradingCampaignDetailsView.tsx b/app/components/UI/Rewards/Views/PerpsTradingCampaignDetailsView.tsx index 0310b69d8ea..14032c9d1db 100644 --- a/app/components/UI/Rewards/Views/PerpsTradingCampaignDetailsView.tsx +++ b/app/components/UI/Rewards/Views/PerpsTradingCampaignDetailsView.tsx @@ -1,6 +1,11 @@ import React, { useCallback, useMemo } from 'react'; import { Pressable, ScrollView } from 'react-native'; -import { useNavigation, useRoute, RouteProp } from '@react-navigation/native'; +import { + useFocusEffect, + useNavigation, + useRoute, + RouteProp, +} from '@react-navigation/native'; import { useSelector } from 'react-redux'; import { Box, @@ -28,11 +33,14 @@ import PerpsTradingCampaignLeaderboard, { import PerpsTradingCampaignPrizePool from '../components/Campaigns/PerpsTradingCampaignPrizePool'; import PerpsTradingCampaignCTA from '../components/Campaigns/PerpsTradingCampaignCTA'; import PerpsCampaignStatsSummary from '../components/Campaigns/PerpsCampaignStatsSummary'; +import PerpsTradingCampaignEndedStats from '../components/Campaigns/PerpsTradingCampaignEndedStats'; +import { CampaignOutcomeBanner } from '../components/Campaigns/CampaignOutcomeBanners'; import { getCampaignStatus } from '../components/Campaigns/CampaignTile.utils'; import { useGetCampaignParticipantStatus } from '../hooks/useGetCampaignParticipantStatus'; import { useGetPerpsTradingCampaignLeaderboard } from '../hooks/useGetPerpsTradingCampaignLeaderboard'; import { useGetPerpsTradingCampaignLeaderboardPosition } from '../hooks/useGetPerpsTradingCampaignLeaderboardPosition'; import { useGetPerpsTradingCampaignVolume } from '../hooks/useGetPerpsTradingCampaignVolume'; +import { usePerpsTradingCampaignParticipantOutcome } from '../hooks/usePerpsTradingCampaignParticipantOutcome'; import { useRewardCampaigns } from '../hooks/useRewardCampaigns'; import { strings } from '../../../../../locales/i18n'; import Routes from '../../../../constants/navigation/Routes'; @@ -53,6 +61,11 @@ export const PERPS_CAMPAIGN_DETAILS_TEST_IDS = { CONTAINER: 'perps-campaign-details-container', } as const; +const sessionWinningViewAutoNavCampaignIds = new Set(); +export function resetPerpsTradingCampaignDetailsSessionAutoNavigationForTests(): void { + sessionWinningViewAutoNavCampaignIds.clear(); +} + const PerpsTradingCampaignDetailsView: React.FC = () => { const tw = useTailwind(); const navigation = useNavigation(); @@ -103,9 +116,14 @@ const PerpsTradingCampaignDetailsView: React.FC = () => { refetch: refetchLeaderboard, } = useGetPerpsTradingCampaignLeaderboard(effectiveCampaignId || undefined); - const { position } = useGetPerpsTradingCampaignLeaderboardPosition( - isOptedIn ? effectiveCampaignId || undefined : undefined, - ); + const { position, isLoading: isPositionLoading } = + useGetPerpsTradingCampaignLeaderboardPosition( + isOptedIn ? effectiveCampaignId || undefined : undefined, + ); + const { outcome: participantOutcome } = + usePerpsTradingCampaignParticipantOutcome( + isComplete && isOptedIn ? effectiveCampaignId || undefined : undefined, + ); const { volume, @@ -130,6 +148,7 @@ const PerpsTradingCampaignDetailsView: React.FC = () => { showStatsSummarySection, showPrizePoolSection, showLeaderboardSection, + showCampaignEndedStats, } = useMemo(() => { if (!campaign) { return { @@ -137,17 +156,32 @@ const PerpsTradingCampaignDetailsView: React.FC = () => { showStatsSummarySection: false, showPrizePoolSection: false, showLeaderboardSection: false, + showCampaignEndedStats: false, }; } + const showEndedStats = + isComplete && !isParticipantStatusLoading && (!isOptedIn || !hasPosition); + return { showHowItWorksSection: - Boolean(campaign.details?.howItWorks) && isActive && !hasPosition, + Boolean(campaign.details?.howItWorks) && + isActive && + (!isOptedIn || (!hasPosition && !isPositionLoading)), showStatsSummarySection: hasPosition, - showPrizePoolSection: isActive, + showPrizePoolSection: isActive || isComplete, showLeaderboardSection: true, + showCampaignEndedStats: showEndedStats, }; - }, [campaign, isActive, hasPosition]); + }, [ + campaign, + isActive, + isComplete, + isOptedIn, + isParticipantStatusLoading, + hasPosition, + isPositionLoading, + ]); const navigateToLeaderboard = useCallback(() => { if (!effectiveCampaignId) return; @@ -163,6 +197,36 @@ const PerpsTradingCampaignDetailsView: React.FC = () => { }); }, [navigation, effectiveCampaignId]); + const navigateToWinningView = useCallback(() => { + if (!effectiveCampaignId) return; + navigation.navigate(Routes.REWARDS_PERPS_TRADING_CAMPAIGN_WINNING_VIEW, { + campaignId: effectiveCampaignId, + campaignName: campaign?.name ?? '', + }); + }, [navigation, effectiveCampaignId, campaign]); + + useFocusEffect( + useCallback(() => { + if ( + !sessionWinningViewAutoNavCampaignIds.has(effectiveCampaignId) && + campaign && + isComplete && + participantOutcome?.winnerVerificationCode && + participantOutcome?.outcomeStatus === 'pending' && + effectiveCampaignId + ) { + sessionWinningViewAutoNavCampaignIds.add(effectiveCampaignId); + navigateToWinningView(); + } + }, [ + campaign, + effectiveCampaignId, + isComplete, + navigateToWinningView, + participantOutcome, + ]), + ); + const navigateToMechanics = useCallback(() => { if (!effectiveCampaignId) return; navigation.navigate(Routes.REWARDS_CAMPAIGN_MECHANICS, { @@ -233,6 +297,30 @@ const PerpsTradingCampaignDetailsView: React.FC = () => { )} + {showCampaignEndedStats && ( + + + {isOptedIn && participantOutcome?.outcomeStatus != null && ( + + )} + + )} + {showStatsSummarySection && ( @@ -258,6 +346,11 @@ const PerpsTradingCampaignDetailsView: React.FC = () => { leaderboardPosition={position} leaderboard={leaderboard} isCampaignComplete={isComplete} + outcomeStatus={participantOutcome?.outcomeStatus} + winnerVerificationCode={ + participantOutcome?.winnerVerificationCode ?? null + } + onWinnerPress={navigateToWinningView} /> )} diff --git a/app/components/UI/Rewards/Views/PerpsTradingCampaignLeaderboardView.tsx b/app/components/UI/Rewards/Views/PerpsTradingCampaignLeaderboardView.tsx index a62780a64e2..b601329431d 100644 --- a/app/components/UI/Rewards/Views/PerpsTradingCampaignLeaderboardView.tsx +++ b/app/components/UI/Rewards/Views/PerpsTradingCampaignLeaderboardView.tsx @@ -113,6 +113,7 @@ const PerpsTradingCampaignLeaderboardView: React.FC = () => { diff --git a/app/components/UI/Rewards/Views/PerpsTradingCampaignStatsView.test.tsx b/app/components/UI/Rewards/Views/PerpsTradingCampaignStatsView.test.tsx index 179886d186f..64fbed6e9ad 100644 --- a/app/components/UI/Rewards/Views/PerpsTradingCampaignStatsView.test.tsx +++ b/app/components/UI/Rewards/Views/PerpsTradingCampaignStatsView.test.tsx @@ -6,6 +6,7 @@ import PerpsTradingCampaignStatsView, { } from './PerpsTradingCampaignStatsView'; import { useGetPerpsTradingCampaignLeaderboardPosition } from '../hooks/useGetPerpsTradingCampaignLeaderboardPosition'; import { useGetCampaignParticipantStatus } from '../hooks/useGetCampaignParticipantStatus'; +import { usePerpsTradingCampaignParticipantOutcome } from '../hooks/usePerpsTradingCampaignParticipantOutcome'; import { CampaignType, type PerpsTradingCampaignLeaderboardPositionDto, @@ -142,6 +143,13 @@ jest.mock('../components/RewardsErrorBanner', () => { jest.mock('../hooks/useGetPerpsTradingCampaignLeaderboardPosition'); jest.mock('../hooks/useGetCampaignParticipantStatus'); +jest.mock('../hooks/usePerpsTradingCampaignParticipantOutcome', () => ({ + usePerpsTradingCampaignParticipantOutcome: jest.fn(() => ({ + outcome: null, + isLoading: false, + hasError: false, + })), +})); jest.mock('../../../../../locales/i18n', () => ({ strings: (key: string) => key, @@ -156,6 +164,10 @@ const mockUseGetParticipant = useGetCampaignParticipantStatus as jest.MockedFunction< typeof useGetCampaignParticipantStatus >; +const mockUsePerpsTradingCampaignParticipantOutcome = + usePerpsTradingCampaignParticipantOutcome as jest.MockedFunction< + typeof usePerpsTradingCampaignParticipantOutcome + >; const basePosition: PerpsTradingCampaignLeaderboardPositionDto = { rank: 4, @@ -197,6 +209,11 @@ describe('PerpsTradingCampaignStatsView', () => { hasError: false, refetch: jest.fn(), }); + mockUsePerpsTradingCampaignParticipantOutcome.mockReturnValue({ + outcome: null, + isLoading: false, + hasError: false, + }); mockUseGetPosition.mockReturnValue({ position: basePosition, isLoading: false, @@ -310,6 +327,30 @@ describe('PerpsTradingCampaignStatsView', () => { ).toBeNull(); }); + it('hides volume and margin StatCells when campaign is complete (only PnL remains)', () => { + const completeCampaign = { + ...mockCampaign, + endDate: '2020-01-01T00:00:00Z', + }; + mockUseSelector.mockImplementation((selector: (s: unknown) => unknown) => + selector({ + rewards: { campaigns: [completeCampaign] }, + }), + ); + const { getByTestId, queryByTestId } = render( + , + ); + expect( + getByTestId(PERPS_CAMPAIGN_STATS_VIEW_TEST_IDS.PERFORMANCE_PNL), + ).toBeDefined(); + expect( + queryByTestId(PERPS_CAMPAIGN_STATS_VIEW_TEST_IDS.PERFORMANCE_VOLUME), + ).toBeNull(); + expect( + queryByTestId(PERPS_CAMPAIGN_STATS_VIEW_TEST_IDS.PERFORMANCE_MARGIN), + ).toBeNull(); + }); + it('hides qualification cards when campaign is complete and shows last-computed after performance when position exists', () => { const completeCampaign = { ...mockCampaign, diff --git a/app/components/UI/Rewards/Views/PerpsTradingCampaignStatsView.tsx b/app/components/UI/Rewards/Views/PerpsTradingCampaignStatsView.tsx index 8e6213ed14d..73df3b431f5 100644 --- a/app/components/UI/Rewards/Views/PerpsTradingCampaignStatsView.tsx +++ b/app/components/UI/Rewards/Views/PerpsTradingCampaignStatsView.tsx @@ -1,4 +1,4 @@ -import React, { useMemo } from 'react'; +import React, { useCallback, useMemo } from 'react'; import { ScrollView } from 'react-native'; import { useNavigation, useRoute, RouteProp } from '@react-navigation/native'; import { useTailwind } from '@metamask/design-system-twrnc-preset'; @@ -35,6 +35,8 @@ import { formatUsd, } from '../utils/formatUtils'; import { getCampaignStatus } from '../components/Campaigns/CampaignTile.utils'; +import { CampaignOutcomeBanner } from '../components/Campaigns/CampaignOutcomeBanners'; +import { usePerpsTradingCampaignParticipantOutcome } from '../hooks/usePerpsTradingCampaignParticipantOutcome'; // eslint-disable-next-line @typescript-eslint/consistent-type-definitions type PerpsTradingCampaignStatsRouteParams = { @@ -113,6 +115,18 @@ const PerpsTradingCampaignStatsView: React.FC = () => { const positionError = hasError && !position; + const { outcome: participantOutcome } = + usePerpsTradingCampaignParticipantOutcome( + isCampaignComplete && isOptedIn ? campaignId : undefined, + ); + + const navigateToWinningView = useCallback(() => { + navigation.navigate(Routes.REWARDS_PERPS_TRADING_CAMPAIGN_WINNING_VIEW, { + campaignId, + campaignName: campaign?.name ?? '', + }); + }, [navigation, campaignId, campaign]); + return ( { isLoading={isLoading} showComputedAt={false} showPnl={false} + isCampaignComplete={isCampaignComplete} /> @@ -164,22 +179,24 @@ const PerpsTradingCampaignStatsView: React.FC = () => { - - : undefined} - testID={PERPS_CAMPAIGN_STATS_VIEW_TEST_IDS.PERFORMANCE_VOLUME} - /> - : undefined} - testID={PERPS_CAMPAIGN_STATS_VIEW_TEST_IDS.PERFORMANCE_MARGIN} - /> - + {!isCampaignComplete && ( + + : undefined} + testID={PERPS_CAMPAIGN_STATS_VIEW_TEST_IDS.PERFORMANCE_VOLUME} + /> + : undefined} + testID={PERPS_CAMPAIGN_STATS_VIEW_TEST_IDS.PERFORMANCE_MARGIN} + /> + + )} {showQualifiedCard && ( { )} + {/* ── Outcome banner (campaign ended) ── */} + {isCampaignComplete && participantOutcome && ( + + )} + {/* ── Error banner ── */} {positionError && ( { + const ReactActual = jest.requireActual('react'); + const { View } = jest.requireActual('react-native'); + return { + __esModule: true, + default: jest.fn(({ testID }: { testID: string }) => + ReactActual.createElement(View, { testID }), + ), + }; +}); + +jest.mock('../hooks/usePerpsTradingCampaignParticipantOutcome', () => ({ + usePerpsTradingCampaignParticipantOutcome: jest.fn(), +})); + +jest.mock('../hooks/useGetPerpsTradingCampaignLeaderboardPosition', () => ({ + useGetPerpsTradingCampaignLeaderboardPosition: jest.fn(), +})); + +jest.mock('@react-navigation/native', () => ({ + useNavigation: () => ({ goBack: jest.fn(), navigate: jest.fn() }), + useRoute: () => ({ + params: { campaignId: 'campaign-perps-1', campaignName: 'Perps Campaign' }, + }), +})); + +jest.mock('@metamask/design-system-twrnc-preset', () => { + const tw = (...args: unknown[]) => args; + tw.style = (...args: unknown[]) => args; + return { useTailwind: () => tw }; +}); + +const mockUseOutcome = + usePerpsTradingCampaignParticipantOutcome as jest.MockedFunction< + typeof usePerpsTradingCampaignParticipantOutcome + >; +const mockUsePosition = + useGetPerpsTradingCampaignLeaderboardPosition as jest.MockedFunction< + typeof useGetPerpsTradingCampaignLeaderboardPosition + >; +const mockCampaignWinningView = CampaignWinningView as jest.MockedFunction< + typeof CampaignWinningView +>; + +describe('PerpsTradingCampaignWinningView', () => { + beforeEach(() => { + jest.clearAllMocks(); + mockUseOutcome.mockReturnValue({ + outcome: { + subscriptionId: 'sub-1', + outcomeStatus: 'pending', + winnerVerificationCode: 'PERPS-WIN-99', + rank: 3, + }, + isLoading: false, + hasError: false, + }); + mockUsePosition.mockReturnValue({ + position: { + rank: 3, + pnl: 1500.25, + notionalVolume: 30000, + marginDeployed: 1200, + qualified: true, + neighbors: [], + computedAt: '2025-08-15T12:00:00.000Z', + }, + isLoading: false, + hasError: false, + hasFetched: true, + refetch: jest.fn(), + }); + }); + + it('renders the container with the Perps testID', () => { + const { getByTestId } = render(); + expect( + getByTestId(PERPS_TRADING_CAMPAIGN_WINNING_VIEW_TEST_IDS.CONTAINER), + ).toBeTruthy(); + }); + + it('passes correct Perps-specific props to CampaignWinningView', () => { + render(); + expect(mockCampaignWinningView).toHaveBeenCalledWith( + expect.objectContaining({ + testID: PERPS_TRADING_CAMPAIGN_WINNING_VIEW_TEST_IDS.CONTAINER, + prizeEmail: 'perpscampaign@consensys.net', + campaignName: 'Perps Campaign', + campaignId: 'campaign-perps-1', + analyticsPageType: 'perps_trading_campaign_winning', + winningCode: 'PERPS-WIN-99', + hasOutcomeLoaded: true, + isLoading: false, + rankDisplay: '3rd', + resultDisplay: '+$1,500.25', + isRankLoading: false, + isResultLoading: false, + fallbackRoute: { + route: Routes.REWARDS_PERPS_TRADING_CAMPAIGN_DETAILS_VIEW, + params: { campaignId: 'campaign-perps-1' }, + }, + }), + {}, + ); + }); + + it('passes winningCode as null when outcome has no code', () => { + mockUseOutcome.mockReturnValue({ + outcome: { + subscriptionId: 'sub-1', + outcomeStatus: 'finalized', + winnerVerificationCode: null, + rank: 21, + }, + isLoading: false, + hasError: false, + }); + render(); + expect(mockCampaignWinningView).toHaveBeenCalledWith( + expect.objectContaining({ + winningCode: null, + hasOutcomeLoaded: true, + }), + {}, + ); + }); + + it('does not mark outcome as loaded until the outcome exists', () => { + mockUseOutcome.mockReturnValue({ + outcome: null, + isLoading: false, + hasError: false, + }); + render(); + expect(mockCampaignWinningView).toHaveBeenCalledWith( + expect.objectContaining({ + winningCode: null, + hasOutcomeLoaded: false, + }), + {}, + ); + }); + + it('passes rankDisplay when rank is available', () => { + render(); + expect(mockCampaignWinningView).toHaveBeenCalledWith( + expect.objectContaining({ + rankDisplay: '3rd', + isRankLoading: false, + isResultLoading: false, + }), + {}, + ); + }); + + it('passes rank from outcome and no result when position is unavailable', () => { + mockUsePosition.mockReturnValue({ + position: null, + isLoading: false, + hasError: false, + hasFetched: true, + refetch: jest.fn(), + }); + render(); + expect(mockCampaignWinningView).toHaveBeenCalledWith( + expect.objectContaining({ + rankDisplay: '3rd', + resultDisplay: null, + isRankLoading: false, + isResultLoading: false, + }), + {}, + ); + }); + + it('does not pass rankDisplay when outcome has no rank', () => { + mockUseOutcome.mockReturnValue({ + outcome: { + subscriptionId: 'sub-1', + outcomeStatus: 'pending', + winnerVerificationCode: 'CODE', + rank: null, + }, + isLoading: false, + hasError: false, + }); + render(); + expect(mockCampaignWinningView).toHaveBeenCalledWith( + expect.objectContaining({ + rankDisplay: null, + isRankLoading: false, + }), + {}, + ); + }); +}); diff --git a/app/components/UI/Rewards/Views/PerpsTradingCampaignWinningView.tsx b/app/components/UI/Rewards/Views/PerpsTradingCampaignWinningView.tsx new file mode 100644 index 00000000000..f2a95fbd700 --- /dev/null +++ b/app/components/UI/Rewards/Views/PerpsTradingCampaignWinningView.tsx @@ -0,0 +1,80 @@ +import React, { useMemo } from 'react'; +import { useRoute, RouteProp } from '@react-navigation/native'; +import { usePerpsTradingCampaignParticipantOutcome } from '../hooks/usePerpsTradingCampaignParticipantOutcome'; +import { formatOrdinalRank, formatSignedUsd } from '../utils/formatUtils'; +import { useGetPerpsTradingCampaignLeaderboardPosition } from '../hooks/useGetPerpsTradingCampaignLeaderboardPosition'; +import CampaignWinningView from './CampaignWinningView'; +import Routes from '../../../../constants/navigation/Routes'; + +const PRIZE_EMAIL = 'perpscampaign@consensys.net'; + +// eslint-disable-next-line @typescript-eslint/consistent-type-definitions +type PerpsTradingCampaignWinningRouteParams = { + RewardsPerpsTradingCampaignWinning: { + campaignId: string; + campaignName: string; + }; +}; + +export const PERPS_TRADING_CAMPAIGN_WINNING_VIEW_TEST_IDS = { + CONTAINER: 'perps-trading-campaign-winning-view-container', +} as const; + +const PerpsTradingCampaignWinningView: React.FC = () => { + const route = + useRoute< + RouteProp< + PerpsTradingCampaignWinningRouteParams, + 'RewardsPerpsTradingCampaignWinning' + > + >(); + const { campaignId, campaignName } = route.params; + + const { outcome, isLoading: isOutcomeLoading } = + usePerpsTradingCampaignParticipantOutcome(campaignId); + const winningCode = outcome?.winnerVerificationCode ?? null; + + const { position, isLoading: positionLoading } = + useGetPerpsTradingCampaignLeaderboardPosition(campaignId); + + const rankDisplay = useMemo(() => { + if (!outcome?.rank) { + return null; + } + return formatOrdinalRank(outcome.rank); + }, [outcome]); + + const resultDisplay = useMemo(() => { + if (!position) return null; + return formatSignedUsd(position.pnl); + }, [position]); + + const fallbackRoute = useMemo( + () => ({ + route: Routes.REWARDS_PERPS_TRADING_CAMPAIGN_DETAILS_VIEW, + params: { campaignId }, + }), + [campaignId], + ); + + return ( + + ); +}; + +export default PerpsTradingCampaignWinningView; diff --git a/app/components/UI/Rewards/Views/RewardsDashboard.test.tsx b/app/components/UI/Rewards/Views/RewardsDashboard.test.tsx index 98fb1ab3dfa..27ccd54e53f 100644 --- a/app/components/UI/Rewards/Views/RewardsDashboard.test.tsx +++ b/app/components/UI/Rewards/Views/RewardsDashboard.test.tsx @@ -4,6 +4,8 @@ import { useSelector } from 'react-redux'; import RewardsDashboard from './RewardsDashboard'; import Routes from '../../../../constants/navigation/Routes'; import { REWARDS_VIEW_SELECTORS } from './RewardsView.constants'; +import { useOndoOutcomeToast } from '../hooks/useOndoOutcomeToast'; +import { usePerpsTradingCampaignEndedOutcomeToast } from '../hooks/usePerpsTradingCampaignEndedOutcomeToast'; // Mock dependencies jest.mock('react-redux', () => ({ @@ -170,6 +172,10 @@ jest.mock('../hooks/useOndoOutcomeToast', () => ({ useOndoOutcomeToast: jest.fn(), })); +jest.mock('../hooks/usePerpsTradingCampaignEndedOutcomeToast', () => ({ + usePerpsTradingCampaignEndedOutcomeToast: jest.fn(), +})); + // Import mocked hooks import { useRewardOptinSummary } from '../hooks/useRewardOptinSummary'; import { useRewardDashboardModals } from '../hooks/useRewardDashboardModals'; @@ -186,6 +192,13 @@ const mockUseRewardDashboardModals = const mockUseBulkLinkState = useBulkLinkState as jest.MockedFunction< typeof useBulkLinkState >; +const mockUseOndoOutcomeToast = useOndoOutcomeToast as jest.MockedFunction< + typeof useOndoOutcomeToast +>; +const mockUsePerpsTradingCampaignEndedOutcomeToast = + usePerpsTradingCampaignEndedOutcomeToast as jest.MockedFunction< + typeof usePerpsTradingCampaignEndedOutcomeToast + >; describe('RewardsDashboard', () => { const mockShowUnlinkedAccountsModal = jest.fn(); @@ -320,6 +333,15 @@ describe('RewardsDashboard', () => { expect(getByText('Rewards')).toBeTruthy(); }); + it('mounts campaign outcome toast hooks on render', () => { + render(); + + expect(mockUseOndoOutcomeToast).toHaveBeenCalledTimes(1); + expect( + mockUsePerpsTradingCampaignEndedOutcomeToast, + ).toHaveBeenCalledTimes(1); + }); + it('renders all child components', () => { // Act const { getByTestId } = render(); diff --git a/app/components/UI/Rewards/Views/RewardsDashboard.tsx b/app/components/UI/Rewards/Views/RewardsDashboard.tsx index ded32bc79da..3613bd3a6d5 100644 --- a/app/components/UI/Rewards/Views/RewardsDashboard.tsx +++ b/app/components/UI/Rewards/Views/RewardsDashboard.tsx @@ -21,7 +21,6 @@ import { RewardsDashboardModalType, } from '../hooks/useRewardDashboardModals'; import { useBulkLinkState } from '../hooks/useBulkLinkState'; -import { useOndoOutcomeToast } from '../hooks/useOndoOutcomeToast'; import { MetaMetricsEvents } from '../../../../core/Analytics'; import { useAnalytics } from '../../../hooks/useAnalytics/useAnalytics'; import useTrackRewardsPageView from '../hooks/useTrackRewardsPageView'; @@ -30,6 +29,8 @@ import CampaignsPreview from '../components/Campaigns/CampaignsPreview'; import EarnRewardsPreview from '../components/EarnRewards/EarnRewardsPreview'; import BenefitsPreview from '../components/Benefits/BenefitsPreview.tsx'; import { ScrollView } from 'react-native'; +import { useOndoOutcomeToast } from '../hooks/useOndoOutcomeToast'; +import { usePerpsTradingCampaignEndedOutcomeToast } from '../hooks/usePerpsTradingCampaignEndedOutcomeToast'; const RewardsDashboard: React.FC = () => { const tw = useTailwind(); @@ -41,6 +42,8 @@ const RewardsDashboard: React.FC = () => { useTrackRewardsPageView({ page_type: 'home' }); useOndoOutcomeToast(); + usePerpsTradingCampaignEndedOutcomeToast(); + const hideUnlinkedAccountsBanner = useSelector( selectHideUnlinkedAccountsBanner, ); diff --git a/app/components/UI/Rewards/components/Campaigns/OndoCampaignOutcomeBanners.test.tsx b/app/components/UI/Rewards/components/Campaigns/CampaignOutcomeBanners.test.tsx similarity index 67% rename from app/components/UI/Rewards/components/Campaigns/OndoCampaignOutcomeBanners.test.tsx rename to app/components/UI/Rewards/components/Campaigns/CampaignOutcomeBanners.test.tsx index 0b623fd0e53..56d7c49a5ba 100644 --- a/app/components/UI/Rewards/components/Campaigns/OndoCampaignOutcomeBanners.test.tsx +++ b/app/components/UI/Rewards/components/Campaigns/CampaignOutcomeBanners.test.tsx @@ -5,8 +5,8 @@ import { WinnerFinalizedBanner, ParticipantFinalizedBanner, ParticipantPendingBanner, - OndoGmCampaignOutcomeBanner, -} from './OndoCampaignOutcomeBanners'; + CampaignOutcomeBanner, +} from './CampaignOutcomeBanners'; jest.mock('../../../../../../locales/i18n', () => ({ strings: (key: string) => key, @@ -28,13 +28,13 @@ jest.mock('../RewardsInfoBanner', () => { }); describe('WinnerPendingBanner', () => { - it('renders title and description', () => { + it('renders title and description using consolidated locale keys', () => { const { getByText } = render(); expect( - getByText('rewards.ondo_outcome_banner.winner_pending.title'), + getByText('rewards.campaign_outcome_banner.winner_pending.title'), ).toBeDefined(); expect( - getByText('rewards.ondo_outcome_banner.winner_pending.description'), + getByText('rewards.campaign_outcome_banner.winner_pending.description'), ).toBeDefined(); }); @@ -43,7 +43,7 @@ describe('WinnerPendingBanner', () => { , ); expect( - getByLabelText('rewards.ondo_outcome_banner.winner_pending.a11y'), + getByLabelText('rewards.campaign_outcome_banner.winner_pending.a11y'), ).toBeDefined(); }); @@ -53,51 +53,53 @@ describe('WinnerPendingBanner', () => { , ); fireEvent.press( - getByLabelText('rewards.ondo_outcome_banner.winner_pending.a11y'), + getByLabelText('rewards.campaign_outcome_banner.winner_pending.a11y'), ); expect(onPress).toHaveBeenCalledTimes(1); }); }); describe('WinnerFinalizedBanner', () => { - it('renders with winner_finalized strings', () => { + it('renders with consolidated winner_finalized strings', () => { const { getByText } = render(); expect( - getByText('rewards.ondo_outcome_banner.winner_finalized.title'), + getByText('rewards.campaign_outcome_banner.winner_finalized.title'), ).toBeDefined(); expect( - getByText('rewards.ondo_outcome_banner.winner_finalized.description'), + getByText('rewards.campaign_outcome_banner.winner_finalized.description'), ).toBeDefined(); }); }); describe('ParticipantFinalizedBanner', () => { - it('renders with participant_finalized strings', () => { + it('renders with consolidated participant_finalized strings', () => { const { getByText } = render(); expect( - getByText('rewards.ondo_outcome_banner.participant_finalized.title'), + getByText('rewards.campaign_outcome_banner.participant_finalized.title'), ).toBeDefined(); expect( getByText( - 'rewards.ondo_outcome_banner.participant_finalized.description', + 'rewards.campaign_outcome_banner.participant_finalized.description', ), ).toBeDefined(); }); }); describe('ParticipantPendingBanner', () => { - it('renders with participant_pending strings', () => { + it('renders with consolidated participant_pending strings', () => { const { getByText } = render(); expect( - getByText('rewards.ondo_outcome_banner.participant_pending.title'), + getByText('rewards.campaign_outcome_banner.participant_pending.title'), ).toBeDefined(); expect( - getByText('rewards.ondo_outcome_banner.participant_pending.description'), + getByText( + 'rewards.campaign_outcome_banner.participant_pending.description', + ), ).toBeDefined(); }); }); -describe('OndoGmCampaignOutcomeBanner', () => { +describe('CampaignOutcomeBanner', () => { const onWinnerPress = jest.fn(); beforeEach(() => { @@ -106,83 +108,83 @@ describe('OndoGmCampaignOutcomeBanner', () => { it('renders WinnerPendingBanner when winner code is present and status is pending', () => { const { getByLabelText } = render( - , ); expect( - getByLabelText('rewards.ondo_outcome_banner.winner_pending.a11y'), + getByLabelText('rewards.campaign_outcome_banner.winner_pending.a11y'), ).toBeDefined(); }); it('renders WinnerFinalizedBanner when winner code is present and status is finalized', () => { const { getByText, queryByLabelText } = render( - , ); expect( - getByText('rewards.ondo_outcome_banner.winner_finalized.title'), + getByText('rewards.campaign_outcome_banner.winner_finalized.title'), ).toBeDefined(); expect( - queryByLabelText('rewards.ondo_outcome_banner.winner_pending.a11y'), + queryByLabelText('rewards.campaign_outcome_banner.winner_pending.a11y'), ).toBeNull(); }); it('renders ParticipantFinalizedBanner when no code and status is finalized', () => { const { getByText } = render( - , ); expect( - getByText('rewards.ondo_outcome_banner.participant_finalized.title'), + getByText('rewards.campaign_outcome_banner.participant_finalized.title'), ).toBeDefined(); }); it('renders ParticipantPendingBanner when no code and status is pending', () => { const { getByText } = render( - , ); expect( - getByText('rewards.ondo_outcome_banner.participant_pending.title'), + getByText('rewards.campaign_outcome_banner.participant_pending.title'), ).toBeDefined(); }); it('renders ParticipantPendingBanner when winnerVerificationCode is undefined', () => { const { getByText } = render( - , ); expect( - getByText('rewards.ondo_outcome_banner.participant_pending.title'), + getByText('rewards.campaign_outcome_banner.participant_pending.title'), ).toBeDefined(); }); it('calls onWinnerPress when WinnerPendingBanner is pressed', () => { const mockOnWinnerPress = jest.fn(); const { getByLabelText } = render( - , ); fireEvent.press( - getByLabelText('rewards.ondo_outcome_banner.winner_pending.a11y'), + getByLabelText('rewards.campaign_outcome_banner.winner_pending.a11y'), ); expect(mockOnWinnerPress).toHaveBeenCalledTimes(1); }); diff --git a/app/components/UI/Rewards/components/Campaigns/OndoCampaignOutcomeBanners.tsx b/app/components/UI/Rewards/components/Campaigns/CampaignOutcomeBanners.tsx similarity index 53% rename from app/components/UI/Rewards/components/Campaigns/OndoCampaignOutcomeBanners.tsx rename to app/components/UI/Rewards/components/Campaigns/CampaignOutcomeBanners.tsx index 5127253bbb7..3402bca2d41 100644 --- a/app/components/UI/Rewards/components/Campaigns/OndoCampaignOutcomeBanners.tsx +++ b/app/components/UI/Rewards/components/Campaigns/CampaignOutcomeBanners.tsx @@ -15,7 +15,7 @@ import { } from '@metamask/design-system-react-native'; import { strings } from '../../../../../../locales/i18n'; import RewardsInfoBanner from '../RewardsInfoBanner'; -import type { OndoGmCampaignParticipantOutcomeStatus } from '../../../../../core/Engine/controllers/rewards-controller/types'; +import type { CampaignParticipantOutcomeStatus } from '../../../../../core/Engine/controllers/rewards-controller/types'; export interface WinnerPendingBannerProps { onPress: () => void; @@ -25,7 +25,7 @@ export const WinnerPendingBanner = React.memo( ({ onPress }) => ( @@ -36,10 +36,12 @@ export const WinnerPendingBanner = React.memo( > - {strings('rewards.ondo_outcome_banner.winner_pending.title')} + {strings('rewards.campaign_outcome_banner.winner_pending.title')} - {strings('rewards.ondo_outcome_banner.winner_pending.description')} + {strings( + 'rewards.campaign_outcome_banner.winner_pending.description', + )} ( export const WinnerFinalizedBanner = React.memo(() => ( )); export const ParticipantFinalizedBanner = React.memo(() => ( )); export const ParticipantPendingBanner = React.memo(() => ( )); -export interface OndoGmCampaignOutcomeBannerProps { - outcomeStatus: OndoGmCampaignParticipantOutcomeStatus; +export interface CampaignOutcomeBannerProps { + outcomeStatus: CampaignParticipantOutcomeStatus; winnerVerificationCode: string | null | undefined; onWinnerPress: () => void; } -export const OndoGmCampaignOutcomeBanner = - React.memo( - ({ outcomeStatus, winnerVerificationCode, onWinnerPress }) => { - const hasCode = Boolean(winnerVerificationCode); - const isFinalized = outcomeStatus === 'finalized'; - if (hasCode && !isFinalized) - return ; - if (hasCode && isFinalized) return ; - if (isFinalized) return ; - return ; - }, - ); +export const CampaignOutcomeBanner = React.memo( + ({ outcomeStatus, winnerVerificationCode, onWinnerPress }) => { + const hasCode = Boolean(winnerVerificationCode); + const isFinalized = outcomeStatus === 'finalized'; + if (hasCode && !isFinalized) + return ; + if (hasCode && isFinalized) return ; + if (isFinalized) return ; + return ; + }, +); diff --git a/app/components/UI/Rewards/components/Campaigns/OndoCampaignStatsSummary.test.tsx b/app/components/UI/Rewards/components/Campaigns/OndoCampaignStatsSummary.test.tsx index 2ba425c2bf6..9926b195451 100644 --- a/app/components/UI/Rewards/components/Campaigns/OndoCampaignStatsSummary.test.tsx +++ b/app/components/UI/Rewards/components/Campaigns/OndoCampaignStatsSummary.test.tsx @@ -28,12 +28,12 @@ jest.mock('@metamask/design-system-twrnc-preset', () => ({ useTailwind: () => ({ style: (...args: unknown[]) => args }), })); -jest.mock('./OndoCampaignOutcomeBanners', () => { +jest.mock('./CampaignOutcomeBanners', () => { const ReactActual = jest.requireActual('react'); const { View } = jest.requireActual('react-native'); return { __esModule: true, - OndoGmCampaignOutcomeBanner: ({ + CampaignOutcomeBanner: ({ outcomeStatus, winnerVerificationCode, }: { diff --git a/app/components/UI/Rewards/components/Campaigns/OndoCampaignStatsSummary.tsx b/app/components/UI/Rewards/components/Campaigns/OndoCampaignStatsSummary.tsx index 0b9fa4b54cf..fd21c22ee46 100644 --- a/app/components/UI/Rewards/components/Campaigns/OndoCampaignStatsSummary.tsx +++ b/app/components/UI/Rewards/components/Campaigns/OndoCampaignStatsSummary.tsx @@ -24,7 +24,7 @@ import { formatPercentChange, formatUsd } from '../../utils/formatUtils'; import { ONDO_GM_REQUIRED_QUALIFIED_DAYS } from '../../utils/ondoCampaignConstants'; import { formatTierDisplayName } from './OndoLeaderboard.utils'; import RewardsErrorBanner from '../RewardsErrorBanner'; -import { OndoGmCampaignOutcomeBanner } from './OndoCampaignOutcomeBanners'; +import { CampaignOutcomeBanner } from './CampaignOutcomeBanners'; const CELL_STYLE = { flex: 1 } as const; @@ -244,7 +244,7 @@ const OndoCampaignStatsSummary: React.FC = ({ {/* Outcome banner (campaign ended) */} {isCampaignComplete && outcomeStatus != null && onWinnerPress != null && ( - ({ strings: (key: string) => key, })); +jest.mock('./CampaignOutcomeBanners', () => { + const ReactActual = jest.requireActual('react'); + const { Pressable, Text } = jest.requireActual('react-native'); + return { + CampaignOutcomeBanner: ({ + outcomeStatus, + winnerVerificationCode, + onWinnerPress, + }: { + outcomeStatus: string; + winnerVerificationCode?: string | null; + onWinnerPress: () => void; + }) => + ReactActual.createElement( + Pressable, + { + testID: `campaign-outcome-banner-${outcomeStatus}-${winnerVerificationCode ?? 'null'}`, + onPress: onWinnerPress, + }, + ReactActual.createElement(Text, null, 'Campaign outcome'), + ), + }; +}); + const TEST_IDS = PERPS_CAMPAIGN_STATS_SUMMARY_TEST_IDS; const mockLeaderboard = { @@ -160,6 +184,20 @@ describe('PerpsCampaignStatsSummary', () => { expect(queryByTestId(TEST_IDS.QUALIFY_FOR_RANK_CARD)).toBeNull(); }); + it('hides volume and margin StatCells when campaign is complete (only rank and PnL remain)', () => { + const { queryByTestId, getByTestId } = render( + , + ); + expect(getByTestId(TEST_IDS.RANK)).toBeDefined(); + expect(getByTestId(TEST_IDS.PNL)).toBeDefined(); + expect(queryByTestId(TEST_IDS.NOTIONAL_VOLUME)).toBeNull(); + expect(queryByTestId(TEST_IDS.MARGIN_DEPLOYED)).toBeNull(); + }); + it("hides You're qualified card when campaign is complete", () => { const { queryByTestId } = render( { ); expect(queryByTestId(TEST_IDS.QUALIFY_FOR_RANK_CARD)).toBeNull(); }); + + it('shows outcome banner for complete campaigns and handles winner press', () => { + const onWinnerPress = jest.fn(); + const { getByTestId } = render( + , + ); + + fireEvent.press( + getByTestId('campaign-outcome-banner-pending-PERPS-WINNER-123'), + ); + expect(onWinnerPress).toHaveBeenCalledTimes(1); + }); + + it('does not show outcome banner before campaign completion', () => { + const { queryByTestId } = render( + , + ); + + expect( + queryByTestId('campaign-outcome-banner-pending-PERPS-WINNER-123'), + ).toBeNull(); + }); }); diff --git a/app/components/UI/Rewards/components/Campaigns/PerpsCampaignStatsSummary.tsx b/app/components/UI/Rewards/components/Campaigns/PerpsCampaignStatsSummary.tsx index 16a2608dfa0..7c3c7a2fe49 100644 --- a/app/components/UI/Rewards/components/Campaigns/PerpsCampaignStatsSummary.tsx +++ b/app/components/UI/Rewards/components/Campaigns/PerpsCampaignStatsSummary.tsx @@ -13,6 +13,7 @@ import { TextVariant, } from '@metamask/design-system-react-native'; import type { + CampaignParticipantOutcomeStatus, PerpsTradingCampaignLeaderboardDto, PerpsTradingCampaignLeaderboardPositionDto, } from '../../../../../core/Engine/controllers/rewards-controller/types'; @@ -20,6 +21,7 @@ import { strings } from '../../../../../../locales/i18n'; import { formatSignedUsd, formatUsd } from '../../utils/formatUtils'; import { PERPS_QUALIFICATION_NOTIONAL_USD } from '../../utils/perpsCampaignConstants'; import { PendingTag, StatCell } from './OndoCampaignStatsSummary'; +import { CampaignOutcomeBanner } from './CampaignOutcomeBanners'; const PERPS_NOTIONAL_THRESHOLD_LABEL = formatUsd( PERPS_QUALIFICATION_NOTIONAL_USD, @@ -43,12 +45,18 @@ export interface PerpsCampaignStatsSummaryProps { leaderboard: PerpsTradingCampaignLeaderboardDto | null; /** When false, pending (not yet qualified) users see a {@link PendingTag} next to rank. */ isCampaignComplete?: boolean; + outcomeStatus?: CampaignParticipantOutcomeStatus; + winnerVerificationCode?: string | null; + onWinnerPress?: () => void; } const PerpsCampaignStatsSummary: React.FC = ({ leaderboardPosition, leaderboard: _leaderboard, isCampaignComplete = false, + outcomeStatus, + winnerVerificationCode, + onWinnerPress, }) => { const isPending = leaderboardPosition != null && !leaderboardPosition.qualified; @@ -125,18 +133,20 @@ const PerpsCampaignStatsSummary: React.FC = ({ testID={PERPS_CAMPAIGN_STATS_SUMMARY_TEST_IDS.PNL} /> - - - - + {!isCampaignComplete && ( + + + + + )} {showQualifiedCard && ( = ({ )} + + {isCampaignComplete && outcomeStatus != null && onWinnerPress != null && ( + + )} ); }; diff --git a/app/components/UI/Rewards/components/Campaigns/PerpsTradingCampaignEndedStats.test.tsx b/app/components/UI/Rewards/components/Campaigns/PerpsTradingCampaignEndedStats.test.tsx new file mode 100644 index 00000000000..f3cb469d9ea --- /dev/null +++ b/app/components/UI/Rewards/components/Campaigns/PerpsTradingCampaignEndedStats.test.tsx @@ -0,0 +1,276 @@ +import React from 'react'; +import { render, fireEvent } from '@testing-library/react-native'; +import PerpsTradingCampaignEndedStats, { + PERPS_CAMPAIGN_ENDED_STATS_TEST_IDS, +} from './PerpsTradingCampaignEndedStats'; +import type { + PerpsTradingCampaignLeaderboardDto, + PerpsTradingCampaignLeaderboardEntry, +} from '../../../../../core/Engine/controllers/rewards-controller/types'; + +jest.mock('../RewardsErrorBanner', () => { + const ReactActual = jest.requireActual('react'); + const RN = jest.requireActual('react-native'); + return { + __esModule: true, + default: (props: { title: string; onConfirm: () => void }) => + ReactActual.createElement( + RN.View, + { testID: 'rewards-error-banner' }, + ReactActual.createElement(RN.Text, null, props.title), + ReactActual.createElement(RN.TouchableOpacity, { + testID: 'rewards-error-banner-retry', + onPress: props.onConfirm, + }), + ), + }; +}); + +jest.mock('@metamask/design-system-react-native', () => { + const actual = jest.requireActual('@metamask/design-system-react-native'); + const ReactActual = jest.requireActual('react'); + const RN = jest.requireActual('react-native'); + return { + ...actual, + Text: (props: Record) => + ReactActual.createElement(RN.Text, props, props.children), + Skeleton: (props: Record) => + ReactActual.createElement(RN.View, { testID: 'skeleton', ...props }), + }; +}); + +jest.mock('@metamask/design-system-twrnc-preset', () => ({ + useTailwind: () => ({ style: (...args: unknown[]) => args }), +})); + +jest.mock('../../../../../../locales/i18n', () => ({ + strings: (key: string) => key, +})); + +jest.mock('../../utils/formatUtils', () => ({ + formatCompactUsd: (value: number) => `$${(value / 1_000_000).toFixed(1)}M`, + formatSignedUsd: (value: number) => { + const sign = value >= 0 ? '+' : '-'; + const abs = Math.abs(value).toLocaleString(); + return `${sign}$${abs}`; + }, +})); + +const makeEntry = ( + rank: number, + pnl: number, + qualified = true, +): PerpsTradingCampaignLeaderboardEntry => ({ + rank, + referralCode: `T-${rank}`, + pnl, + qualified, +}); + +const makeLeaderboard = ( + entriesCount: number, + totalParticipants?: number, + topPnl = 50_000, +): PerpsTradingCampaignLeaderboardDto => { + const entries = Array.from({ length: entriesCount }, (_, i) => + makeEntry(i + 1, topPnl - i * 1000), + ); + return { + campaignId: 'perps-1', + computedAt: '2026-01-01T00:00:00Z', + totalParticipants: totalParticipants ?? entriesCount, + entries, + }; +}; + +describe('PerpsTradingCampaignEndedStats', () => { + it('renders all four stat cells with correct values when leaderboard has 20+ entries', () => { + const { getByTestId } = render( + , + ); + + expect( + getByTestId(PERPS_CAMPAIGN_ENDED_STATS_TEST_IDS.CONTAINER), + ).toBeTruthy(); + expect( + getByTestId(PERPS_CAMPAIGN_ENDED_STATS_TEST_IDS.TOTAL_PARTICIPANTS).props + .children, + ).toBe((200).toLocaleString()); + expect( + getByTestId(PERPS_CAMPAIGN_ENDED_STATS_TEST_IDS.TOTAL_VOLUME).props + .children, + ).toBe('$27.5M'); + expect( + getByTestId(PERPS_CAMPAIGN_ENDED_STATS_TEST_IDS.TOP_PNL).props.children, + ).toBe('+$80,000'); + // Leaderboard has 25 entries (>= 20) → fixed 20 winners + expect( + getByTestId(PERPS_CAMPAIGN_ENDED_STATS_TEST_IDS.WINNERS).props.children, + ).toBe('20'); + }); + + it('shows dash for winners when leaderboard has fewer than 20 entries', () => { + const { getByTestId } = render( + , + ); + + expect( + getByTestId(PERPS_CAMPAIGN_ENDED_STATS_TEST_IDS.WINNERS).props.children, + ).toBe('-'); + }); + + it('shows dashes when leaderboard and volume are null', () => { + const { getByTestId } = render( + , + ); + + expect( + getByTestId(PERPS_CAMPAIGN_ENDED_STATS_TEST_IDS.TOTAL_PARTICIPANTS).props + .children, + ).toBe('-'); + expect( + getByTestId(PERPS_CAMPAIGN_ENDED_STATS_TEST_IDS.TOTAL_VOLUME).props + .children, + ).toBe('-'); + expect( + getByTestId(PERPS_CAMPAIGN_ENDED_STATS_TEST_IDS.TOP_PNL).props.children, + ).toBe('-'); + expect( + getByTestId(PERPS_CAMPAIGN_ENDED_STATS_TEST_IDS.WINNERS).props.children, + ).toBe('-'); + }); + + it('renders skeletons while data is loading', () => { + const { getAllByTestId } = render( + , + ); + + const skeletons = getAllByTestId('skeleton'); + expect(skeletons.length).toBeGreaterThanOrEqual(3); + }); + + it('handles a leaderboard with no entries (no top PnL)', () => { + const empty: PerpsTradingCampaignLeaderboardDto = { + campaignId: 'perps-1', + computedAt: '2026-01-01T00:00:00Z', + totalParticipants: 0, + entries: [], + }; + + const { getByTestId } = render( + , + ); + + expect( + getByTestId(PERPS_CAMPAIGN_ENDED_STATS_TEST_IDS.TOTAL_PARTICIPANTS).props + .children, + ).toBe('0'); + expect( + getByTestId(PERPS_CAMPAIGN_ENDED_STATS_TEST_IDS.TOP_PNL).props.children, + ).toBe('-'); + expect( + getByTestId(PERPS_CAMPAIGN_ENDED_STATS_TEST_IDS.WINNERS).props.children, + ).toBe('-'); + }); + + it('shows error banner when both sources fail and triggers both retries', () => { + const onRetryLeaderboard = jest.fn(); + const onRetryVolume = jest.fn(); + + const { getByTestId } = render( + , + ); + + expect(getByTestId('rewards-error-banner')).toBeTruthy(); + fireEvent.press(getByTestId('rewards-error-banner-retry')); + expect(onRetryLeaderboard).toHaveBeenCalledTimes(1); + expect(onRetryVolume).toHaveBeenCalledTimes(1); + }); + + it('shows error banner when only leaderboard fails; volume still renders', () => { + const { getByTestId } = render( + , + ); + + expect(getByTestId('rewards-error-banner')).toBeTruthy(); + expect( + getByTestId(PERPS_CAMPAIGN_ENDED_STATS_TEST_IDS.TOTAL_VOLUME).props + .children, + ).toBe('$27.5M'); + }); + + it('does not render error banner when there are no errors', () => { + const { queryByTestId } = render( + , + ); + + expect(queryByTestId('rewards-error-banner')).toBeNull(); + }); + + it('renders negative top PnL with error color and a minus sign', () => { + const negativeTop: PerpsTradingCampaignLeaderboardDto = { + campaignId: 'perps-1', + computedAt: '2026-01-01T00:00:00Z', + totalParticipants: 1, + entries: [makeEntry(1, -5_000)], + }; + + const { getByTestId } = render( + , + ); + + expect( + getByTestId(PERPS_CAMPAIGN_ENDED_STATS_TEST_IDS.TOP_PNL).props.children, + ).toBe('-$5,000'); + }); +}); diff --git a/app/components/UI/Rewards/components/Campaigns/PerpsTradingCampaignEndedStats.tsx b/app/components/UI/Rewards/components/Campaigns/PerpsTradingCampaignEndedStats.tsx new file mode 100644 index 00000000000..af3a1616288 --- /dev/null +++ b/app/components/UI/Rewards/components/Campaigns/PerpsTradingCampaignEndedStats.tsx @@ -0,0 +1,153 @@ +import React, { useMemo } from 'react'; +import { + Box, + BoxFlexDirection, + FontWeight, + Text, + TextColor, + TextVariant, +} from '@metamask/design-system-react-native'; +import type { PerpsTradingCampaignLeaderboardDto } from '../../../../../core/Engine/controllers/rewards-controller/types'; +import { StatCell } from './OndoCampaignStatsSummary'; +import RewardsErrorBanner from '../RewardsErrorBanner'; +import { strings } from '../../../../../../locales/i18n'; +import { formatCompactUsd, formatSignedUsd } from '../../utils/formatUtils'; + +const PERPS_WINNERS_CAP = 20; + +export const PERPS_CAMPAIGN_ENDED_STATS_TEST_IDS = { + CONTAINER: 'perps-campaign-ended-stats-container', + TOTAL_PARTICIPANTS: 'perps-campaign-ended-stats-total-participants', + TOTAL_VOLUME: 'perps-campaign-ended-stats-total-volume', + TOP_PNL: 'perps-campaign-ended-stats-top-pnl', + WINNERS: 'perps-campaign-ended-stats-winners', +} as const; + +interface PerpsTradingCampaignEndedStatsProps { + leaderboard: PerpsTradingCampaignLeaderboardDto | null; + totalNotionalVolume: string | null; + isLeaderboardLoading: boolean; + isVolumeLoading: boolean; + hasLeaderboardError?: boolean; + hasVolumeError?: boolean; + onRetryLeaderboard?: () => void; + onRetryVolume?: () => void; +} + +const PerpsTradingCampaignEndedStats: React.FC< + PerpsTradingCampaignEndedStatsProps +> = ({ + leaderboard, + totalNotionalVolume, + isLeaderboardLoading, + isVolumeLoading, + hasLeaderboardError, + hasVolumeError, + onRetryLeaderboard, + onRetryVolume, +}) => { + const stats = useMemo(() => { + if (!leaderboard) return null; + + const entries = leaderboard.entries ?? []; + const totalParticipants = leaderboard.totalParticipants; + const topPnl = + entries.length > 0 ? Math.max(...entries.map((e) => e.pnl)) : null; + const hasFullLeaderboard = entries.length >= PERPS_WINNERS_CAP; + return { totalParticipants, topPnl, hasFullLeaderboard }; + }, [leaderboard]); + + const isLeaderboardSkeletonVisible = isLeaderboardLoading && !leaderboard; + const isVolumeSkeletonVisible = isVolumeLoading && !totalNotionalVolume; + + const hasError = + (hasLeaderboardError && !leaderboard) || + (hasVolumeError && !totalNotionalVolume); + + const totalParticipantsValue = stats + ? stats.totalParticipants.toLocaleString() + : '-'; + + const totalVolumeValue = totalNotionalVolume + ? formatCompactUsd(parseFloat(totalNotionalVolume)) + : '-'; + + const topPnlValue = + stats?.topPnl != null ? formatSignedUsd(stats.topPnl) : '-'; + + const topPnlColor = + stats?.topPnl != null && stats.topPnl >= 0 + ? TextColor.SuccessDefault + : TextColor.ErrorDefault; + + const winnersValue = stats?.hasFullLeaderboard + ? String(PERPS_WINNERS_CAP) + : '-'; + + return ( + + + {strings('rewards.perps_trading_campaign.stats_title')} + + {hasError && ( + { + onRetryLeaderboard?.(); + onRetryVolume?.(); + }} + confirmButtonLabel={strings( + 'rewards.perps_trading_campaign.stats_retry', + )} + /> + )} + + + + + + + + + + ); +}; + +export default PerpsTradingCampaignEndedStats; diff --git a/app/components/UI/Rewards/components/Campaigns/PerpsTradingCampaignStatsHeader.test.tsx b/app/components/UI/Rewards/components/Campaigns/PerpsTradingCampaignStatsHeader.test.tsx index d103b8e8c58..2894eaf9e28 100644 --- a/app/components/UI/Rewards/components/Campaigns/PerpsTradingCampaignStatsHeader.test.tsx +++ b/app/components/UI/Rewards/components/Campaigns/PerpsTradingCampaignStatsHeader.test.tsx @@ -104,6 +104,16 @@ describe('PerpsTradingCampaignStatsHeader', () => { expect(queryByTestId(TEST_IDS.QUALIFIED_ICON)).toBeNull(); }); + it('hides the pending tag when the campaign is complete', () => { + const { queryByTestId } = render( + , + ); + expect(queryByTestId(TEST_IDS.PENDING_TAG)).toBeNull(); + }); + it('shows em dashes for rank and PnL when position is null', () => { const { getByTestId } = render( , diff --git a/app/components/UI/Rewards/components/Campaigns/PerpsTradingCampaignStatsHeader.tsx b/app/components/UI/Rewards/components/Campaigns/PerpsTradingCampaignStatsHeader.tsx index 63c4bdee9a7..31d3623c69b 100644 --- a/app/components/UI/Rewards/components/Campaigns/PerpsTradingCampaignStatsHeader.tsx +++ b/app/components/UI/Rewards/components/Campaigns/PerpsTradingCampaignStatsHeader.tsx @@ -38,6 +38,8 @@ interface PerpsTradingCampaignStatsHeaderProps { showPnl?: boolean; /** When true, shows formatted `computedAt` time on the same row as PnL, right-aligned in alternative text color. */ showComputedAt?: boolean; + /** When true, suppresses the "Pending" tag — qualification is locked once the campaign ends. */ + isCampaignComplete?: boolean; } const PerpsTradingCampaignStatsHeader: React.FC< @@ -47,6 +49,7 @@ const PerpsTradingCampaignStatsHeader: React.FC< isLoading = false, showPnl = true, showComputedAt = true, + isCampaignComplete = false, }) => { const tw = useTailwind(); @@ -80,7 +83,7 @@ const PerpsTradingCampaignStatsHeader: React.FC< {strings('rewards.perps_trading_campaign.label_your_rank')} - {isPending && ( + {isPending && !isCampaignComplete && ( )} {isQualified && ( diff --git a/app/components/UI/Rewards/components/ReferralDetails/CopyableField.tsx b/app/components/UI/Rewards/components/ReferralDetails/CopyableField.tsx index 7023d15b341..6bf5b7a0cc9 100644 --- a/app/components/UI/Rewards/components/ReferralDetails/CopyableField.tsx +++ b/app/components/UI/Rewards/components/ReferralDetails/CopyableField.tsx @@ -14,7 +14,7 @@ import { import { Skeleton } from '../../../../../component-library/components-temp/Skeleton'; interface CopyableFieldProps { - label: string; + label?: string; value?: string | null; onCopy?: () => void; valueLoading?: boolean; @@ -40,9 +40,11 @@ const CopyableField: React.FC = ({ return ( - - {label} - + {label && ( + + {label} + + )} ({ + ToastContext: { Consumer: jest.fn(), Provider: jest.fn() }, +})); + +jest.mock('../../../../component-library/components/Toast/Toast.types', () => ({ + ToastVariants: { Icon: 'Icon', App: 'App', Plain: 'Plain' }, + ButtonIconVariant: { Icon: 'Icon' }, +})); + +jest.mock('../../../../component-library/components/Icons/Icon', () => ({ + IconName: { Close: 'Close', Confirmation: 'Confirmation', Star: 'Star' }, +})); + +jest.mock('react', () => ({ + ...jest.requireActual('react'), + useContext: jest.fn(), + useCallback: jest.fn((fn) => fn), + useMemo: jest.fn((fn) => fn()), +})); + +jest.mock('react-redux', () => ({ + useDispatch: jest.fn(), + useSelector: jest.fn(), +})); + +jest.mock('@react-navigation/native', () => ({ + useFocusEffect: jest.fn(), + useNavigation: jest.fn(), +})); + +jest.mock('../../../../util/haptics', () => ({ + playNotification: jest.fn(), + NotificationMoment: { + Success: 'Success', + Warning: 'Warning', + Error: 'Error', + }, +})); + +jest.mock('../../../../../locales/i18n', () => ({ + strings: jest.fn((key: string, params?: Record) => { + if (params?.campaignName) return `${key}:${params.campaignName}`; + return key; + }), +})); + +jest.mock('../../../../util/theme', () => { + const actual = jest.requireActual('../../../../util/theme'); + return { + ...actual, + useAppThemeFromContext: () => actual.mockTheme, + }; +}); + +jest.mock('../../../../reducers/rewards', () => ({ + dismissCampaignOutcomeToast: jest.fn(), +})); + +jest.mock('../../../../reducers/rewards/selectors', () => ({ + selectCampaigns: jest.fn(), + selectDismissedCampaignOutcomeToasts: jest.fn(), +})); + +jest.mock('../../../../selectors/rewards', () => ({ + selectRewardsSubscriptionId: jest.fn(), +})); + +jest.mock('../components/Campaigns/CampaignTile.utils', () => ({ + getCampaignStatus: jest.fn(() => 'complete'), +})); + +const mockDispatch = jest.fn(); +const mockNavigate = jest.fn(); +const mockShowToast = jest.fn(); +const mockCloseToast = jest.fn(); +const mockToastRef = { + current: { showToast: mockShowToast, closeToast: mockCloseToast }, +}; + +const mockUseDispatch = useDispatch as jest.MockedFunction; +const mockUseSelector = useSelector as jest.MockedFunction; +const mockUseFocusEffect = useFocusEffect as jest.MockedFunction< + typeof useFocusEffect +>; +const mockUseNavigation = useNavigation as jest.MockedFunction< + typeof useNavigation +>; +const mockDismissCampaignOutcomeToast = + dismissCampaignOutcomeToast as jest.MockedFunction< + typeof dismissCampaignOutcomeToast + >; + +const SUBSCRIPTION_ID = 'sub-123'; +const CAMPAIGN_ID = 'campaign-456'; +const CAMPAIGN_NAME = 'Test Campaign'; + +const mockUseOutcome = jest.fn(); + +const makeCompletedCampaign = (id = CAMPAIGN_ID, endDate = '2025-01-01') => ({ + id, + name: CAMPAIGN_NAME, + type: CampaignType.ONDO_HOLDING, + endDate, + startDate: '2024-01-01', +}); + +const WINNER_NAV = { + route: 'WinnerRoute', + params: { campaignId: CAMPAIGN_ID }, +}; +const NON_WINNER_NAV = { + route: 'NonWinnerRoute', + params: { campaignId: CAMPAIGN_ID }, +}; + +const mockConfig = { + campaignType: CampaignType.ONDO_HOLDING, + useOutcome: mockUseOutcome, + getWinnerNavigation: jest.fn(() => WINNER_NAV), + getNonWinnerNavigation: jest.fn(() => NON_WINNER_NAV), +}; + +function setupDefaults({ + campaigns = [makeCompletedCampaign()], + dismissed = {}, + subscriptionId = SUBSCRIPTION_ID, + outcome = null, +}: { + campaigns?: ReturnType[]; + dismissed?: Record; + subscriptionId?: string | null; + outcome?: BaseCampaignParticipantOutcomeDto | null; +} = {}) { + mockUseSelector.mockImplementation((selector) => { + if (selector === selectCampaigns) return campaigns; + if (selector === selectDismissedCampaignOutcomeToasts) return dismissed; + if (selector === selectRewardsSubscriptionId) return subscriptionId; + return undefined; + }); + mockUseOutcome.mockReturnValue({ + outcome, + isLoading: false, + hasError: false, + }); +} + +describe('useCampaignOutcomeToast', () => { + beforeEach(() => { + jest.clearAllMocks(); + mockUseDispatch.mockReturnValue(mockDispatch); + mockUseNavigation.mockReturnValue({ navigate: mockNavigate } as never); + (useContext as jest.Mock).mockReturnValue({ toastRef: mockToastRef }); + mockUseFocusEffect.mockImplementation((cb) => { + cb(); + }); + mockDismissCampaignOutcomeToast.mockReturnValue({ + type: 'rewards/dismissCampaignOutcomeToast', + } as never); + mockConfig.getWinnerNavigation.mockReturnValue(WINNER_NAV); + mockConfig.getNonWinnerNavigation.mockReturnValue(NON_WINNER_NAV); + }); + + describe('does not show toast when', () => { + it('outcome is null', () => { + setupDefaults({ outcome: null }); + renderHook(() => useCampaignOutcomeToast(mockConfig)); + expect(mockShowToast).not.toHaveBeenCalled(); + }); + + it('no campaigns match the campaignType', () => { + setupDefaults({ campaigns: [] }); + renderHook(() => useCampaignOutcomeToast(mockConfig)); + expect(mockShowToast).not.toHaveBeenCalled(); + }); + + it('subscriptionId is missing', () => { + setupDefaults({ + subscriptionId: null, + outcome: { + subscriptionId: '', + outcomeStatus: 'pending', + winnerVerificationCode: 'CODE', + }, + }); + renderHook(() => useCampaignOutcomeToast(mockConfig)); + expect(mockShowToast).not.toHaveBeenCalled(); + }); + + it('winner toast was already dismissed', () => { + const key = `${CAMPAIGN_ID}:${SUBSCRIPTION_ID}:winner`; + setupDefaults({ + outcome: { + subscriptionId: SUBSCRIPTION_ID, + outcomeStatus: 'pending', + winnerVerificationCode: 'CODE', + }, + dismissed: { [key]: true }, + }); + renderHook(() => useCampaignOutcomeToast(mockConfig)); + expect(mockShowToast).not.toHaveBeenCalled(); + }); + + it('non_winner toast was already dismissed', () => { + const key = `${CAMPAIGN_ID}:${SUBSCRIPTION_ID}:non_winner`; + setupDefaults({ + outcome: { + subscriptionId: SUBSCRIPTION_ID, + outcomeStatus: 'finalized', + winnerVerificationCode: null, + }, + dismissed: { [key]: true }, + }); + renderHook(() => useCampaignOutcomeToast(mockConfig)); + expect(mockShowToast).not.toHaveBeenCalled(); + }); + + it('outcome is finalized with a verification code (neither variant)', () => { + setupDefaults({ + outcome: { + subscriptionId: SUBSCRIPTION_ID, + outcomeStatus: 'finalized', + winnerVerificationCode: 'CODE', + }, + }); + renderHook(() => useCampaignOutcomeToast(mockConfig)); + expect(mockShowToast).not.toHaveBeenCalled(); + }); + }); + + describe('winner toast', () => { + const winnerOutcome: BaseCampaignParticipantOutcomeDto = { + subscriptionId: SUBSCRIPTION_ID, + outcomeStatus: 'pending', + winnerVerificationCode: 'WINNER-XYZ', + }; + + it('shows Plain variant toast with trophy startAccessory', () => { + setupDefaults({ outcome: winnerOutcome }); + renderHook(() => useCampaignOutcomeToast(mockConfig)); + expect(mockShowToast).toHaveBeenCalledWith( + expect.objectContaining({ + variant: ToastVariants.Plain, + hasNoTimeout: true, + startAccessory: expect.anything(), + }), + ); + }); + + it('uses consolidated winner locale keys', () => { + setupDefaults({ outcome: winnerOutcome }); + renderHook(() => useCampaignOutcomeToast(mockConfig)); + expect(mockShowToast).toHaveBeenCalledWith( + expect.objectContaining({ + labelOptions: [ + { + label: 'rewards.campaign_outcome_toast.winner.title', + isBold: true, + }, + ], + descriptionOptions: { + description: `rewards.campaign_outcome_toast.winner.description:${CAMPAIGN_NAME}`, + }, + linkButtonOptions: expect.objectContaining({ + label: 'rewards.campaign_outcome_toast.winner.cta', + }), + }), + ); + }); + + it('shows close button with correct config', () => { + setupDefaults({ outcome: winnerOutcome }); + renderHook(() => useCampaignOutcomeToast(mockConfig)); + expect(mockShowToast).toHaveBeenCalledWith( + expect.objectContaining({ + closeButtonOptions: expect.objectContaining({ + variant: ButtonIconVariant.Icon, + iconName: IconName.Close, + }), + }), + ); + }); + + it('fires success haptic via playNotification', () => { + setupDefaults({ outcome: winnerOutcome }); + renderHook(() => useCampaignOutcomeToast(mockConfig)); + expect(playNotification).toHaveBeenCalledWith(NotificationMoment.Success); + }); + }); + + describe('non-winner toast', () => { + const nonWinnerOutcome: BaseCampaignParticipantOutcomeDto = { + subscriptionId: SUBSCRIPTION_ID, + outcomeStatus: 'finalized', + winnerVerificationCode: null, + }; + + it('shows Icon variant toast with Confirmation icon', () => { + setupDefaults({ outcome: nonWinnerOutcome }); + renderHook(() => useCampaignOutcomeToast(mockConfig)); + expect(mockShowToast).toHaveBeenCalledWith( + expect.objectContaining({ + variant: ToastVariants.Icon, + iconName: IconName.Confirmation, + backgroundColor: 'transparent', + hasNoTimeout: true, + }), + ); + }); + + it('uses consolidated non_winner locale keys', () => { + setupDefaults({ outcome: nonWinnerOutcome }); + renderHook(() => useCampaignOutcomeToast(mockConfig)); + expect(mockShowToast).toHaveBeenCalledWith( + expect.objectContaining({ + labelOptions: [ + { + label: 'rewards.campaign_outcome_toast.non_winner.title', + isBold: true, + }, + ], + descriptionOptions: { + description: `rewards.campaign_outcome_toast.non_winner.description:${CAMPAIGN_NAME}`, + }, + linkButtonOptions: expect.objectContaining({ + label: 'rewards.campaign_outcome_toast.non_winner.cta', + }), + }), + ); + }); + + it('fires warning haptic via playNotification', () => { + setupDefaults({ outcome: nonWinnerOutcome }); + renderHook(() => useCampaignOutcomeToast(mockConfig)); + expect(playNotification).toHaveBeenCalledWith(NotificationMoment.Warning); + }); + }); + + describe('handleDismiss', () => { + it('dispatches dismissCampaignOutcomeToast with variant winner and closes toast', () => { + setupDefaults({ + outcome: { + subscriptionId: SUBSCRIPTION_ID, + outcomeStatus: 'pending', + winnerVerificationCode: 'CODE', + }, + }); + renderHook(() => useCampaignOutcomeToast(mockConfig)); + + const closeButtonOptions = + mockShowToast.mock.calls[0][0].closeButtonOptions; + closeButtonOptions.onPress(); + + expect(mockDismissCampaignOutcomeToast).toHaveBeenCalledWith({ + campaignId: CAMPAIGN_ID, + subscriptionId: SUBSCRIPTION_ID, + variant: 'winner', + }); + expect(mockCloseToast).toHaveBeenCalled(); + }); + + it('dispatches dismissCampaignOutcomeToast with variant non_winner', () => { + setupDefaults({ + outcome: { + subscriptionId: SUBSCRIPTION_ID, + outcomeStatus: 'finalized', + winnerVerificationCode: null, + }, + }); + renderHook(() => useCampaignOutcomeToast(mockConfig)); + + const closeButtonOptions = + mockShowToast.mock.calls[0][0].closeButtonOptions; + closeButtonOptions.onPress(); + + expect(mockDismissCampaignOutcomeToast).toHaveBeenCalledWith({ + campaignId: CAMPAIGN_ID, + subscriptionId: SUBSCRIPTION_ID, + variant: 'non_winner', + }); + }); + }); + + describe('handleCta', () => { + it('navigates to winner route and dismisses for winner variant', () => { + setupDefaults({ + outcome: { + subscriptionId: SUBSCRIPTION_ID, + outcomeStatus: 'pending', + winnerVerificationCode: 'CODE', + }, + }); + renderHook(() => useCampaignOutcomeToast(mockConfig)); + + const { onPress } = mockShowToast.mock.calls[0][0].linkButtonOptions; + onPress(); + + expect(mockNavigate).toHaveBeenCalledWith( + WINNER_NAV.route as never, + WINNER_NAV.params as never, + ); + expect(mockDismissCampaignOutcomeToast).toHaveBeenCalledWith( + expect.objectContaining({ variant: 'winner' }), + ); + }); + + it('navigates to non-winner route and dismisses for non_winner variant', () => { + setupDefaults({ + outcome: { + subscriptionId: SUBSCRIPTION_ID, + outcomeStatus: 'finalized', + winnerVerificationCode: null, + }, + }); + renderHook(() => useCampaignOutcomeToast(mockConfig)); + + const { onPress } = mockShowToast.mock.calls[0][0].linkButtonOptions; + onPress(); + + expect(mockNavigate).toHaveBeenCalledWith( + NON_WINNER_NAV.route as never, + NON_WINNER_NAV.params as never, + ); + expect(mockDismissCampaignOutcomeToast).toHaveBeenCalledWith( + expect.objectContaining({ variant: 'non_winner' }), + ); + }); + }); + + describe('cleanup on blur', () => { + it('calls closeToast in cleanup when screen blurs', () => { + let cleanupFn: (() => void) | undefined; + mockUseFocusEffect.mockImplementation((cb) => { + const cleanup = cb(); + if (typeof cleanup === 'function') cleanupFn = cleanup; + }); + + setupDefaults({ + outcome: { + subscriptionId: SUBSCRIPTION_ID, + outcomeStatus: 'pending', + winnerVerificationCode: 'CODE', + }, + }); + renderHook(() => useCampaignOutcomeToast(mockConfig)); + + expect(cleanupFn).toBeDefined(); + cleanupFn?.(); + expect(mockCloseToast).toHaveBeenCalled(); + }); + + it('does not return cleanup when variant is null', () => { + let cleanupFn: (() => void) | undefined; + mockUseFocusEffect.mockImplementation((cb) => { + const result = cb(); + if (typeof result === 'function') cleanupFn = result; + }); + + setupDefaults({ outcome: null }); + renderHook(() => useCampaignOutcomeToast(mockConfig)); + + expect(cleanupFn).toBeUndefined(); + }); + }); + + describe('edge cases', () => { + it('handles null toastRef gracefully', () => { + (useContext as jest.Mock).mockReturnValue({ toastRef: null }); + setupDefaults({ + outcome: { + subscriptionId: SUBSCRIPTION_ID, + outcomeStatus: 'pending', + winnerVerificationCode: 'CODE', + }, + }); + expect(() => + renderHook(() => useCampaignOutcomeToast(mockConfig)), + ).not.toThrow(); + }); + }); +}); diff --git a/app/components/UI/Rewards/hooks/useCampaignOutcomeToast.ts b/app/components/UI/Rewards/hooks/useCampaignOutcomeToast.ts new file mode 100644 index 00000000000..4ea89f6df6e --- /dev/null +++ b/app/components/UI/Rewards/hooks/useCampaignOutcomeToast.ts @@ -0,0 +1,174 @@ +import { useCallback, useContext, useMemo } from 'react'; +import { + useFocusEffect, + useNavigation, + type NavigationProp, + type ParamListBase, +} from '@react-navigation/native'; +import { useDispatch, useSelector } from 'react-redux'; +import { strings } from '../../../../../locales/i18n'; +import { ToastContext } from '../../../../component-library/components/Toast'; +import type { + BaseCampaignParticipantOutcomeDto, + CampaignType, + CampaignDto, +} from '../../../../core/Engine/controllers/rewards-controller/types'; +import { getCampaignStatus } from '../components/Campaigns/CampaignTile.utils'; +import { dismissCampaignOutcomeToast } from '../../../../reducers/rewards'; +import { + selectCampaigns, + selectDismissedCampaignOutcomeToasts, +} from '../../../../reducers/rewards/selectors'; +import { selectRewardsSubscriptionId } from '../../../../selectors/rewards'; +import useRewardsToast from './useRewardsToast'; + +export interface CampaignOutcomeToastConfig { + campaignType: CampaignType; + useOutcome: (id: string | undefined) => { + outcome: BaseCampaignParticipantOutcomeDto | null; + }; + getWinnerNavigation: (campaign: CampaignDto) => { + route: string; + params: object; + }; + getNonWinnerNavigation: (campaign: CampaignDto) => { + route: string; + params: object; + }; +} + +export function useCampaignOutcomeToast( + config: CampaignOutcomeToastConfig, +): void { + const { + campaignType, + useOutcome, + getWinnerNavigation, + getNonWinnerNavigation, + } = config; + + const dispatch = useDispatch(); + const { toastRef } = useContext(ToastContext); + const { showToast, RewardsToastOptions } = useRewardsToast(); + const navigation = useNavigation>(); + + const subscriptionId = useSelector(selectRewardsSubscriptionId); + const campaigns = useSelector(selectCampaigns); + const dismissed = useSelector(selectDismissedCampaignOutcomeToasts); + + const targetCampaign = useMemo(() => { + const completed = campaigns + .filter( + (c) => c.type === campaignType && getCampaignStatus(c) === 'complete', + ) + .sort( + (a, b) => new Date(b.endDate).getTime() - new Date(a.endDate).getTime(), + ); + return completed[0] ?? null; + }, [campaigns, campaignType]); + + const { outcome } = useOutcome(targetCampaign?.id); + + // Standardized variant derivation: winner = has code and not yet finalized + const variant = useMemo((): 'winner' | 'non_winner' | null => { + if (!outcome) return null; + if ( + outcome.winnerVerificationCode && + outcome.outcomeStatus !== 'finalized' + ) { + return 'winner'; + } + if ( + outcome.outcomeStatus === 'finalized' && + !outcome.winnerVerificationCode + ) { + return 'non_winner'; + } + return null; + }, [outcome]); + + const isDismissed = useMemo(() => { + if (!variant || !targetCampaign || !subscriptionId) return true; + const key = `${targetCampaign.id}:${subscriptionId}:${variant}`; + return dismissed[key] === true; + }, [variant, targetCampaign, subscriptionId, dismissed]); + + const handleDismiss = useCallback(() => { + if (!variant || !targetCampaign || !subscriptionId) return; + dispatch( + dismissCampaignOutcomeToast({ + campaignId: targetCampaign.id, + subscriptionId, + variant, + }), + ); + toastRef?.current?.closeToast(); + }, [variant, targetCampaign, subscriptionId, dispatch, toastRef]); + + const handleCta = useCallback(() => { + if (!targetCampaign || !variant) return; + handleDismiss(); + const nav = + variant === 'winner' + ? getWinnerNavigation(targetCampaign) + : getNonWinnerNavigation(targetCampaign); + navigation.navigate(nav.route, nav.params); + }, [ + variant, + targetCampaign, + handleDismiss, + navigation, + getWinnerNavigation, + getNonWinnerNavigation, + ]); + + useFocusEffect( + useCallback(() => { + if (!variant || isDismissed || !targetCampaign) return; + + const isWinner = variant === 'winner'; + if (isWinner) { + showToast( + RewardsToastOptions.outcomeWinner({ + title: strings('rewards.campaign_outcome_toast.winner.title'), + description: strings( + 'rewards.campaign_outcome_toast.winner.description', + { campaignName: targetCampaign.name ?? '' }, + ), + ctaLabel: strings('rewards.campaign_outcome_toast.winner.cta'), + onCtaPress: handleCta, + onClosePress: handleDismiss, + }), + ); + } else { + showToast( + RewardsToastOptions.outcomeNonWinner({ + title: strings('rewards.campaign_outcome_toast.non_winner.title'), + description: strings( + 'rewards.campaign_outcome_toast.non_winner.description', + { campaignName: targetCampaign.name ?? '' }, + ), + ctaLabel: strings('rewards.campaign_outcome_toast.non_winner.cta'), + onCtaPress: handleCta, + onClosePress: handleDismiss, + }), + ); + } + + return () => { + toastRef?.current?.closeToast(); + }; + }, [ + variant, + isDismissed, + targetCampaign, + toastRef, + showToast, + RewardsToastOptions, + handleCta, + handleDismiss, + ]), + ); +} + +export default useCampaignOutcomeToast; diff --git a/app/components/UI/Rewards/hooks/useCampaignParticipantOutcome.test.ts b/app/components/UI/Rewards/hooks/useCampaignParticipantOutcome.test.ts new file mode 100644 index 00000000000..012cbe4549a --- /dev/null +++ b/app/components/UI/Rewards/hooks/useCampaignParticipantOutcome.test.ts @@ -0,0 +1,173 @@ +import { renderHook, act } from '@testing-library/react-hooks'; +import { useSelector } from 'react-redux'; +import Engine from '../../../../core/Engine'; +import { selectRewardsSubscriptionId } from '../../../../selectors/rewards'; +import { selectCampaignParticipantOptedIn } from '../../../../reducers/rewards/selectors'; +import { useCampaignParticipantOutcome } from './useCampaignParticipantOutcome'; +import type { BaseCampaignParticipantOutcomeDto } from '../../../../core/Engine/controllers/rewards-controller/types'; + +jest.mock('react-redux', () => ({ + useSelector: jest.fn(), +})); + +jest.mock('../../../../core/Engine', () => ({ + controllerMessenger: { call: jest.fn() }, +})); + +jest.mock('../../../../selectors/rewards', () => ({ + selectRewardsSubscriptionId: jest.fn(), +})); + +jest.mock('../../../../reducers/rewards/selectors', () => ({ + selectCampaignParticipantOptedIn: jest.fn(), +})); + +const mockCall = Engine.controllerMessenger.call as jest.MockedFunction< + typeof Engine.controllerMessenger.call +>; +const mockUseSelector = useSelector as jest.MockedFunction; +const mockSelectCampaignParticipantOptedIn = + selectCampaignParticipantOptedIn as jest.MockedFunction< + typeof selectCampaignParticipantOptedIn + >; + +const CAMPAIGN_ID = 'campaign-123'; +const SUBSCRIPTION_ID = 'sub-456'; +const MESSENGER_ACTION = 'RewardsController:getOndoCampaignParticipantOutcome'; +const CONFIG = { messengerAction: MESSENGER_ACTION }; + +const MOCK_OUTCOME: BaseCampaignParticipantOutcomeDto = { + subscriptionId: SUBSCRIPTION_ID, + outcomeStatus: 'pending', + winnerVerificationCode: 'WINNER-XYZ', +}; + +function setupSelectors({ + subscriptionId = SUBSCRIPTION_ID, + isOptedIn = true, +}: { + subscriptionId?: string | null; + isOptedIn?: boolean; +} = {}) { + const participantOptedInSelector = jest.fn().mockReturnValue(isOptedIn); + mockSelectCampaignParticipantOptedIn.mockReturnValue( + participantOptedInSelector, + ); + mockUseSelector.mockImplementation((selector) => { + if (selector === selectRewardsSubscriptionId) return subscriptionId; + if (selector === participantOptedInSelector) return isOptedIn; + return undefined; + }); +} + +describe('useCampaignParticipantOutcome', () => { + beforeEach(() => { + jest.clearAllMocks(); + }); + + it('fetches and returns outcome when subscriptionId, campaignId, and isOptedIn are truthy', async () => { + setupSelectors(); + mockCall.mockResolvedValue(MOCK_OUTCOME); + + const { result, waitForNextUpdate } = renderHook(() => + useCampaignParticipantOutcome(CAMPAIGN_ID, CONFIG), + ); + + await act(async () => { + await waitForNextUpdate(); + }); + + expect(mockCall).toHaveBeenCalledWith( + MESSENGER_ACTION, + CAMPAIGN_ID, + SUBSCRIPTION_ID, + ); + expect(result.current.outcome).toEqual(MOCK_OUTCOME); + expect(result.current.isLoading).toBe(false); + expect(result.current.hasError).toBe(false); + }); + + it('returns null outcome when campaignId is undefined', async () => { + setupSelectors(); + const { result, waitForNextUpdate } = renderHook(() => + useCampaignParticipantOutcome(undefined, CONFIG), + ); + await act(async () => { + await waitForNextUpdate().catch(() => undefined); + }); + expect(result.current.outcome).toBeNull(); + expect(result.current.isLoading).toBe(false); + expect(result.current.hasError).toBe(false); + expect(mockCall).not.toHaveBeenCalled(); + }); + + it('returns null outcome when subscriptionId is missing', async () => { + setupSelectors({ subscriptionId: null }); + const { result, waitForNextUpdate } = renderHook(() => + useCampaignParticipantOutcome(CAMPAIGN_ID, CONFIG), + ); + await act(async () => { + await waitForNextUpdate().catch(() => undefined); + }); + expect(result.current.outcome).toBeNull(); + expect(result.current.isLoading).toBe(false); + expect(result.current.hasError).toBe(false); + expect(mockCall).not.toHaveBeenCalled(); + }); + + it('returns null outcome when user is not opted in', async () => { + setupSelectors({ isOptedIn: false }); + const { result, waitForNextUpdate } = renderHook(() => + useCampaignParticipantOutcome(CAMPAIGN_ID, CONFIG), + ); + await act(async () => { + await waitForNextUpdate().catch(() => undefined); + }); + expect(result.current.outcome).toBeNull(); + expect(result.current.isLoading).toBe(false); + expect(result.current.hasError).toBe(false); + expect(mockCall).not.toHaveBeenCalled(); + }); + + it('sets hasError and clears outcome when the fetch throws', async () => { + setupSelectors(); + mockCall.mockRejectedValue(new Error('fetch failed')); + + const { result, waitForNextUpdate } = renderHook(() => + useCampaignParticipantOutcome(CAMPAIGN_ID, CONFIG), + ); + + await act(async () => { + await waitForNextUpdate(); + }); + + expect(result.current.outcome).toBeNull(); + expect(result.current.isLoading).toBe(false); + expect(result.current.hasError).toBe(true); + }); + + it('resets state when campaignId changes to undefined', async () => { + setupSelectors(); + mockCall.mockResolvedValue(MOCK_OUTCOME); + + const initialProps: { id: string | undefined } = { id: CAMPAIGN_ID }; + const { result, waitForNextUpdate, rerender } = renderHook( + ({ id }: { id: string | undefined }) => + useCampaignParticipantOutcome(id, CONFIG), + { initialProps }, + ); + + await act(async () => { + await waitForNextUpdate(); + }); + expect(result.current.outcome).toEqual(MOCK_OUTCOME); + + rerender({ id: undefined }); + await act(async () => { + await waitForNextUpdate().catch(() => undefined); + }); + expect(result.current.outcome).toBeNull(); + expect(result.current.isLoading).toBe(false); + expect(result.current.hasError).toBe(false); + }); +}); diff --git a/app/components/UI/Rewards/hooks/useCampaignParticipantOutcome.ts b/app/components/UI/Rewards/hooks/useCampaignParticipantOutcome.ts new file mode 100644 index 00000000000..1f10639c665 --- /dev/null +++ b/app/components/UI/Rewards/hooks/useCampaignParticipantOutcome.ts @@ -0,0 +1,68 @@ +import { useCallback, useEffect, useState } from 'react'; +import { useSelector } from 'react-redux'; +import Engine from '../../../../core/Engine'; +import { selectRewardsSubscriptionId } from '../../../../selectors/rewards'; +import { selectCampaignParticipantOptedIn } from '../../../../reducers/rewards/selectors'; +import type { BaseCampaignParticipantOutcomeDto } from '../../../../core/Engine/controllers/rewards-controller/types'; + +export interface UseCampaignParticipantOutcomeResult< + T extends BaseCampaignParticipantOutcomeDto, +> { + outcome: T | null; + isLoading: boolean; + hasError: boolean; +} + +export interface CampaignOutcomeFetchConfig { + messengerAction: string; +} + +export function useCampaignParticipantOutcome< + T extends BaseCampaignParticipantOutcomeDto, +>( + campaignId: string | undefined, + config: CampaignOutcomeFetchConfig, +): UseCampaignParticipantOutcomeResult { + const subscriptionId = useSelector(selectRewardsSubscriptionId); + const isOptedIn = useSelector( + selectCampaignParticipantOptedIn(subscriptionId, campaignId), + ); + const [outcome, setOutcome] = useState(null); + const [isLoading, setIsLoading] = useState(false); + const [hasError, setHasError] = useState(false); + + const fetchOutcome = useCallback(async (): Promise => { + if (!subscriptionId || !campaignId || !isOptedIn) { + setOutcome(null); + setIsLoading(false); + setHasError(false); + return; + } + + try { + setIsLoading(true); + setHasError(false); + const result = await Engine.controllerMessenger.call( + config.messengerAction as Parameters< + typeof Engine.controllerMessenger.call + >[0], + campaignId, + subscriptionId, + ); + setOutcome(result as T); + } catch { + setOutcome(null); + setHasError(true); + } finally { + setIsLoading(false); + } + }, [campaignId, subscriptionId, isOptedIn, config.messengerAction]); + + useEffect(() => { + fetchOutcome(); + }, [fetchOutcome]); + + return { outcome, isLoading, hasError }; +} + +export default useCampaignParticipantOutcome; diff --git a/app/components/UI/Rewards/hooks/useGetOndoCampaignActivity.test.ts b/app/components/UI/Rewards/hooks/useGetOndoCampaignActivity.test.ts index c0bed6ad4a6..f424932ae16 100644 --- a/app/components/UI/Rewards/hooks/useGetOndoCampaignActivity.test.ts +++ b/app/components/UI/Rewards/hooks/useGetOndoCampaignActivity.test.ts @@ -3,11 +3,11 @@ import { waitFor } from '@testing-library/react-native'; import { useSelector, useDispatch } from 'react-redux'; import { useGetOndoCampaignActivity } from './useGetOndoCampaignActivity'; import Engine from '../../../../core/Engine'; +import { selectRewardsSubscriptionId } from '../../../../selectors/rewards'; import { - selectRewardsSubscriptionId, selectCampaignParticipantOptedIn, -} from '../../../../selectors/rewards'; -import { selectOndoCampaignActivityById } from '../../../../reducers/rewards/selectors'; + selectOndoCampaignActivityById, +} from '../../../../reducers/rewards/selectors'; import { setOndoCampaignActivity } from '../../../../reducers/rewards'; import type { OndoGmActivityEntryDto } from '../../../../core/Engine/controllers/rewards-controller/types'; @@ -22,10 +22,10 @@ jest.mock('../../../../core/Engine', () => ({ jest.mock('../../../../selectors/rewards', () => ({ selectRewardsSubscriptionId: jest.fn(), - selectCampaignParticipantOptedIn: jest.fn(), })); jest.mock('../../../../reducers/rewards/selectors', () => ({ + selectCampaignParticipantOptedIn: jest.fn(), selectOndoCampaignActivityById: jest.fn(), })); diff --git a/app/components/UI/Rewards/hooks/useGetOndoCampaignActivity.ts b/app/components/UI/Rewards/hooks/useGetOndoCampaignActivity.ts index 3c28894340a..92faacd5610 100644 --- a/app/components/UI/Rewards/hooks/useGetOndoCampaignActivity.ts +++ b/app/components/UI/Rewards/hooks/useGetOndoCampaignActivity.ts @@ -1,11 +1,11 @@ import { useCallback, useEffect, useRef, useState } from 'react'; import { useDispatch, useSelector } from 'react-redux'; import Engine from '../../../../core/Engine'; +import { selectRewardsSubscriptionId } from '../../../../selectors/rewards'; import { - selectRewardsSubscriptionId, selectCampaignParticipantOptedIn, -} from '../../../../selectors/rewards'; -import { selectOndoCampaignActivityById } from '../../../../reducers/rewards/selectors'; + selectOndoCampaignActivityById, +} from '../../../../reducers/rewards/selectors'; import { setOndoCampaignActivity } from '../../../../reducers/rewards'; import type { OndoGmActivityEntryDto } from '../../../../core/Engine/controllers/rewards-controller/types'; diff --git a/app/components/UI/Rewards/hooks/useGetOndoLeaderboardPosition.test.ts b/app/components/UI/Rewards/hooks/useGetOndoLeaderboardPosition.test.ts index f3170271b60..2910af46256 100644 --- a/app/components/UI/Rewards/hooks/useGetOndoLeaderboardPosition.test.ts +++ b/app/components/UI/Rewards/hooks/useGetOndoLeaderboardPosition.test.ts @@ -2,11 +2,11 @@ import { renderHook, act } from '@testing-library/react-hooks'; import { useSelector, useDispatch } from 'react-redux'; import { useGetOndoLeaderboardPosition } from './useGetOndoLeaderboardPosition'; import Engine from '../../../../core/Engine'; +import { selectRewardsSubscriptionId } from '../../../../selectors/rewards'; import { - selectRewardsSubscriptionId, selectCampaignParticipantOptedIn, -} from '../../../../selectors/rewards'; -import { selectOndoCampaignLeaderboardPositionById } from '../../../../reducers/rewards/selectors'; + selectOndoCampaignLeaderboardPositionById, +} from '../../../../reducers/rewards/selectors'; import { setOndoCampaignLeaderboardPosition } from '../../../../reducers/rewards'; import { useInvalidateByRewardEvents } from './useInvalidateByRewardEvents'; import type { CampaignLeaderboardPositionDto } from '../../../../core/Engine/controllers/rewards-controller/types'; @@ -26,10 +26,10 @@ jest.mock('./useInvalidateByRewardEvents', () => ({ jest.mock('../../../../selectors/rewards', () => ({ selectRewardsSubscriptionId: jest.fn(), - selectCampaignParticipantOptedIn: jest.fn(), })); jest.mock('../../../../reducers/rewards/selectors', () => ({ + selectCampaignParticipantOptedIn: jest.fn(), selectOndoCampaignLeaderboardPositionById: jest.fn(), })); @@ -83,16 +83,18 @@ interface SelectorState { function setupSelectors(state: SelectorState) { const isOptedIn = state.isOptedIn ?? true; const mockPositionSelector = jest.fn().mockReturnValue(state.position); - const mockOptedInSelector = jest.fn().mockReturnValue(isOptedIn); + const mockParticipantOptedInSelector = jest.fn().mockReturnValue(isOptedIn); mockSelectCampaignLeaderboardPositionById.mockReturnValue( mockPositionSelector, ); - mockSelectCampaignParticipantOptedIn.mockReturnValue(mockOptedInSelector); + mockSelectCampaignParticipantOptedIn.mockReturnValue( + mockParticipantOptedInSelector, + ); mockUseSelector.mockImplementation((selector) => { if (selector === selectRewardsSubscriptionId) return state.subscriptionId; if (selector === mockPositionSelector) return state.position; - if (selector === mockOptedInSelector) return isOptedIn; + if (selector === mockParticipantOptedInSelector) return isOptedIn; return undefined; }); } diff --git a/app/components/UI/Rewards/hooks/useGetOndoLeaderboardPosition.ts b/app/components/UI/Rewards/hooks/useGetOndoLeaderboardPosition.ts index be4f0b96cc3..9b0bccab4f3 100644 --- a/app/components/UI/Rewards/hooks/useGetOndoLeaderboardPosition.ts +++ b/app/components/UI/Rewards/hooks/useGetOndoLeaderboardPosition.ts @@ -1,11 +1,11 @@ import { useCallback, useEffect, useMemo, useState } from 'react'; import { useDispatch, useSelector } from 'react-redux'; import Engine from '../../../../core/Engine'; +import { selectRewardsSubscriptionId } from '../../../../selectors/rewards'; import { - selectRewardsSubscriptionId, selectCampaignParticipantOptedIn, -} from '../../../../selectors/rewards'; -import { selectOndoCampaignLeaderboardPositionById } from '../../../../reducers/rewards/selectors'; + selectOndoCampaignLeaderboardPositionById, +} from '../../../../reducers/rewards/selectors'; import { setOndoCampaignLeaderboardPosition } from '../../../../reducers/rewards'; import type { CampaignLeaderboardPositionDto } from '../../../../core/Engine/controllers/rewards-controller/types'; import { useInvalidateByRewardEvents } from './useInvalidateByRewardEvents'; diff --git a/app/components/UI/Rewards/hooks/useGetOndoPortfolioPosition.test.ts b/app/components/UI/Rewards/hooks/useGetOndoPortfolioPosition.test.ts index 80300f1f62c..332ad4b7415 100644 --- a/app/components/UI/Rewards/hooks/useGetOndoPortfolioPosition.test.ts +++ b/app/components/UI/Rewards/hooks/useGetOndoPortfolioPosition.test.ts @@ -3,11 +3,11 @@ import { waitFor } from '@testing-library/react-native'; import { useSelector, useDispatch } from 'react-redux'; import { useGetOndoPortfolioPosition } from './useGetOndoPortfolioPosition'; import Engine from '../../../../core/Engine'; +import { selectRewardsSubscriptionId } from '../../../../selectors/rewards'; import { - selectRewardsSubscriptionId, selectCampaignParticipantOptedIn, -} from '../../../../selectors/rewards'; -import { selectOndoCampaignPortfolioById } from '../../../../reducers/rewards/selectors'; + selectOndoCampaignPortfolioById, +} from '../../../../reducers/rewards/selectors'; import { setOndoCampaignPortfolioPosition } from '../../../../reducers/rewards'; import { useInvalidateByRewardEvents } from './useInvalidateByRewardEvents'; import type { OndoGmPortfolioDto } from '../../../../core/Engine/controllers/rewards-controller/types'; @@ -27,10 +27,10 @@ jest.mock('./useInvalidateByRewardEvents', () => ({ jest.mock('../../../../selectors/rewards', () => ({ selectRewardsSubscriptionId: jest.fn(), - selectCampaignParticipantOptedIn: jest.fn(), })); jest.mock('../../../../reducers/rewards/selectors', () => ({ + selectCampaignParticipantOptedIn: jest.fn(), selectOndoCampaignPortfolioById: jest.fn(), })); diff --git a/app/components/UI/Rewards/hooks/useGetOndoPortfolioPosition.ts b/app/components/UI/Rewards/hooks/useGetOndoPortfolioPosition.ts index 2270b597a59..e0f960916cc 100644 --- a/app/components/UI/Rewards/hooks/useGetOndoPortfolioPosition.ts +++ b/app/components/UI/Rewards/hooks/useGetOndoPortfolioPosition.ts @@ -1,11 +1,11 @@ import { useCallback, useEffect, useMemo, useState } from 'react'; import { useDispatch, useSelector } from 'react-redux'; import Engine from '../../../../core/Engine'; +import { selectRewardsSubscriptionId } from '../../../../selectors/rewards'; import { - selectRewardsSubscriptionId, selectCampaignParticipantOptedIn, -} from '../../../../selectors/rewards'; -import { selectOndoCampaignPortfolioById } from '../../../../reducers/rewards/selectors'; + selectOndoCampaignPortfolioById, +} from '../../../../reducers/rewards/selectors'; import { setOndoCampaignPortfolioPosition } from '../../../../reducers/rewards'; import type { OndoGmPortfolioDto } from '../../../../core/Engine/controllers/rewards-controller/types'; import { useInvalidateByRewardEvents } from './useInvalidateByRewardEvents'; diff --git a/app/components/UI/Rewards/hooks/useGetPerpsTradingCampaignLeaderboardPosition.test.ts b/app/components/UI/Rewards/hooks/useGetPerpsTradingCampaignLeaderboardPosition.test.ts index 320a3921c75..d85a3b027cf 100644 --- a/app/components/UI/Rewards/hooks/useGetPerpsTradingCampaignLeaderboardPosition.test.ts +++ b/app/components/UI/Rewards/hooks/useGetPerpsTradingCampaignLeaderboardPosition.test.ts @@ -2,11 +2,11 @@ import { renderHook, act } from '@testing-library/react-hooks'; import { useSelector, useDispatch } from 'react-redux'; import { useGetPerpsTradingCampaignLeaderboardPosition } from './useGetPerpsTradingCampaignLeaderboardPosition'; import Engine from '../../../../core/Engine'; +import { selectRewardsSubscriptionId } from '../../../../selectors/rewards'; import { - selectRewardsSubscriptionId, selectCampaignParticipantOptedIn, -} from '../../../../selectors/rewards'; -import { selectPerpsTradingCampaignLeaderboardPositionById } from '../../../../reducers/rewards/selectors'; + selectPerpsTradingCampaignLeaderboardPositionById, +} from '../../../../reducers/rewards/selectors'; import { setPerpsTradingCampaignLeaderboardPosition } from '../../../../reducers/rewards'; import { useInvalidateByRewardEvents } from './useInvalidateByRewardEvents'; import type { PerpsTradingCampaignLeaderboardPositionDto } from '../../../../core/Engine/controllers/rewards-controller/types'; @@ -26,10 +26,10 @@ jest.mock('./useInvalidateByRewardEvents', () => ({ jest.mock('../../../../selectors/rewards', () => ({ selectRewardsSubscriptionId: jest.fn(), - selectCampaignParticipantOptedIn: jest.fn(), })); jest.mock('../../../../reducers/rewards/selectors', () => ({ + selectCampaignParticipantOptedIn: jest.fn(), selectPerpsTradingCampaignLeaderboardPositionById: jest.fn(), })); @@ -79,14 +79,16 @@ interface SelectorState { function setupSelectors(state: SelectorState) { const isOptedIn = state.isOptedIn ?? true; const mockPositionSelector = jest.fn().mockReturnValue(state.position); - const mockOptedInSelector = jest.fn().mockReturnValue(isOptedIn); + const mockParticipantOptedInSelector = jest.fn().mockReturnValue(isOptedIn); mockSelectPositionById.mockReturnValue(mockPositionSelector); - mockSelectCampaignParticipantOptedIn.mockReturnValue(mockOptedInSelector); + mockSelectCampaignParticipantOptedIn.mockReturnValue( + mockParticipantOptedInSelector, + ); mockUseSelector.mockImplementation((selector) => { if (selector === selectRewardsSubscriptionId) return state.subscriptionId; if (selector === mockPositionSelector) return state.position; - if (selector === mockOptedInSelector) return isOptedIn; + if (selector === mockParticipantOptedInSelector) return isOptedIn; return undefined; }); } diff --git a/app/components/UI/Rewards/hooks/useGetPerpsTradingCampaignLeaderboardPosition.ts b/app/components/UI/Rewards/hooks/useGetPerpsTradingCampaignLeaderboardPosition.ts index aaa206cc69f..aedd1ff533a 100644 --- a/app/components/UI/Rewards/hooks/useGetPerpsTradingCampaignLeaderboardPosition.ts +++ b/app/components/UI/Rewards/hooks/useGetPerpsTradingCampaignLeaderboardPosition.ts @@ -1,11 +1,11 @@ import { useCallback, useEffect, useMemo, useState } from 'react'; import { useDispatch, useSelector } from 'react-redux'; import Engine from '../../../../core/Engine'; +import { selectRewardsSubscriptionId } from '../../../../selectors/rewards'; import { - selectRewardsSubscriptionId, selectCampaignParticipantOptedIn, -} from '../../../../selectors/rewards'; -import { selectPerpsTradingCampaignLeaderboardPositionById } from '../../../../reducers/rewards/selectors'; + selectPerpsTradingCampaignLeaderboardPositionById, +} from '../../../../reducers/rewards/selectors'; import { setPerpsTradingCampaignLeaderboardPosition } from '../../../../reducers/rewards'; import type { PerpsTradingCampaignLeaderboardPositionDto } from '../../../../core/Engine/controllers/rewards-controller/types'; import { useInvalidateByRewardEvents } from './useInvalidateByRewardEvents'; diff --git a/app/components/UI/Rewards/hooks/useLinkAccountAddress.test.ts b/app/components/UI/Rewards/hooks/useLinkAccountAddress.test.ts index f41f2351164..b614232da40 100644 --- a/app/components/UI/Rewards/hooks/useLinkAccountAddress.test.ts +++ b/app/components/UI/Rewards/hooks/useLinkAccountAddress.test.ts @@ -89,6 +89,14 @@ describe('useLinkAccountAddress', () => { loading: jest.fn().mockReturnValue({ variant: 'loading', }), + outcomeWinner: jest.fn().mockReturnValue({ + variant: 'plain', + hapticsType: 'success', + }), + outcomeNonWinner: jest.fn().mockReturnValue({ + variant: 'icon', + hapticsType: 'warning', + }), }; const mockAccount: InternalAccount = { diff --git a/app/components/UI/Rewards/hooks/useLinkAccountGroup.test.ts b/app/components/UI/Rewards/hooks/useLinkAccountGroup.test.ts index 5e99c8b0f20..7c5c8603ec7 100644 --- a/app/components/UI/Rewards/hooks/useLinkAccountGroup.test.ts +++ b/app/components/UI/Rewards/hooks/useLinkAccountGroup.test.ts @@ -110,6 +110,14 @@ describe('useLinkAccountGroup', () => { loading: jest.fn().mockReturnValue({ variant: 'loading', }), + outcomeWinner: jest.fn().mockReturnValue({ + variant: 'plain', + hapticsType: 'success', + }), + outcomeNonWinner: jest.fn().mockReturnValue({ + variant: 'icon', + hapticsType: 'warning', + }), }; // Mock account data diff --git a/app/components/UI/Rewards/hooks/useOndoCampaignParticipantOutcome.test.ts b/app/components/UI/Rewards/hooks/useOndoCampaignParticipantOutcome.test.ts index 5dc86d8617a..6a4189dfcfb 100644 --- a/app/components/UI/Rewards/hooks/useOndoCampaignParticipantOutcome.test.ts +++ b/app/components/UI/Rewards/hooks/useOndoCampaignParticipantOutcome.test.ts @@ -1,149 +1,65 @@ -import { renderHook, act } from '@testing-library/react-hooks'; -import { useSelector } from 'react-redux'; -import Engine from '../../../../core/Engine'; -import { selectRewardsSubscriptionId } from '../../../../selectors/rewards'; -import { selectCampaignParticipantStatus } from '../../../../reducers/rewards/selectors'; +import { renderHook } from '@testing-library/react-hooks'; import { useOndoCampaignParticipantOutcome } from './useOndoCampaignParticipantOutcome'; -import type { OndoGmCampaignParticipantOutcomeDto } from '../../../../core/Engine/controllers/rewards-controller/types'; +import { useCampaignParticipantOutcome } from './useCampaignParticipantOutcome'; -jest.mock('react-redux', () => ({ - useSelector: jest.fn(), +jest.mock('./useCampaignParticipantOutcome', () => ({ + useCampaignParticipantOutcome: jest.fn(), })); -jest.mock('../../../../core/Engine', () => ({ - controllerMessenger: { call: jest.fn() }, -})); - -jest.mock('../../../../selectors/rewards', () => ({ - selectRewardsSubscriptionId: jest.fn(), -})); - -jest.mock('../../../../reducers/rewards/selectors', () => ({ - selectCampaignParticipantStatus: jest.fn(), -})); - -const mockCall = Engine.controllerMessenger.call as jest.MockedFunction< - typeof Engine.controllerMessenger.call ->; -const mockUseSelector = useSelector as jest.MockedFunction; -const mockSelectCampaignParticipantStatus = - selectCampaignParticipantStatus as jest.MockedFunction< - typeof selectCampaignParticipantStatus +const mockUseCampaignParticipantOutcome = + useCampaignParticipantOutcome as jest.MockedFunction< + typeof useCampaignParticipantOutcome >; const CAMPAIGN_ID = 'campaign-123'; -const SUBSCRIPTION_ID = 'sub-456'; - -const MOCK_OUTCOME: OndoGmCampaignParticipantOutcomeDto = { - subscriptionId: SUBSCRIPTION_ID, - outcomeStatus: 'pending', - winnerVerificationCode: 'WINNER-XYZ', -}; - -function setupSelectors({ - subscriptionId = SUBSCRIPTION_ID, - isOptedIn = true, -}: { - subscriptionId?: string | null; - isOptedIn?: boolean; -} = {}) { - const participantStatusSelector = jest - .fn() - .mockReturnValue(isOptedIn ? { optedIn: true } : null); - mockSelectCampaignParticipantStatus.mockReturnValue( - participantStatusSelector, - ); - mockUseSelector.mockImplementation((selector) => { - if (selector === selectRewardsSubscriptionId) return subscriptionId; - if (selector === participantStatusSelector) - return isOptedIn ? { optedIn: true } : null; - return undefined; - }); -} describe('useOndoCampaignParticipantOutcome', () => { beforeEach(() => { jest.clearAllMocks(); - }); - - it('returns null outcome and no loading when campaignId is undefined', async () => { - setupSelectors(); - const { result, waitForNextUpdate } = renderHook(() => - useOndoCampaignParticipantOutcome(undefined), - ); - await act(async () => { - await waitForNextUpdate().catch(() => undefined); + mockUseCampaignParticipantOutcome.mockReturnValue({ + outcome: null, + isLoading: false, + hasError: false, }); - expect(result.current.outcome).toBeNull(); - expect(result.current.isLoading).toBe(false); - expect(result.current.hasError).toBe(false); - expect(mockCall).not.toHaveBeenCalled(); }); - it('returns null outcome when subscriptionId is missing', async () => { - setupSelectors({ subscriptionId: null }); - const { result, waitForNextUpdate } = renderHook(() => - useOndoCampaignParticipantOutcome(CAMPAIGN_ID), - ); - await act(async () => { - await waitForNextUpdate().catch(() => undefined); - }); - expect(result.current.outcome).toBeNull(); - expect(result.current.isLoading).toBe(false); - expect(result.current.hasError).toBe(false); - expect(mockCall).not.toHaveBeenCalled(); - }); + it('delegates to useCampaignParticipantOutcome with the Ondo messenger action', () => { + renderHook(() => useOndoCampaignParticipantOutcome(CAMPAIGN_ID)); - it('returns null outcome when user is not opted in', async () => { - setupSelectors({ isOptedIn: false }); - const { result, waitForNextUpdate } = renderHook(() => - useOndoCampaignParticipantOutcome(CAMPAIGN_ID), + expect(mockUseCampaignParticipantOutcome).toHaveBeenCalledWith( + CAMPAIGN_ID, + { + messengerAction: 'RewardsController:getOndoCampaignParticipantOutcome', + }, ); - await act(async () => { - await waitForNextUpdate().catch(() => undefined); - }); - expect(result.current.outcome).toBeNull(); - expect(result.current.isLoading).toBe(false); - expect(result.current.hasError).toBe(false); - expect(mockCall).not.toHaveBeenCalled(); }); - it('fetches outcome and returns it when all conditions are met', async () => { - setupSelectors(); - mockCall.mockResolvedValue(MOCK_OUTCOME); - - const { result, waitForNextUpdate } = renderHook(() => - useOndoCampaignParticipantOutcome(CAMPAIGN_ID), - ); + it('passes undefined campaignId through to the generic hook', () => { + renderHook(() => useOndoCampaignParticipantOutcome(undefined)); - await act(async () => { - await waitForNextUpdate(); + expect(mockUseCampaignParticipantOutcome).toHaveBeenCalledWith(undefined, { + messengerAction: 'RewardsController:getOndoCampaignParticipantOutcome', }); - - expect(mockCall).toHaveBeenCalledWith( - 'RewardsController:getOndoCampaignParticipantOutcome', - CAMPAIGN_ID, - SUBSCRIPTION_ID, - ); - expect(result.current.outcome).toEqual(MOCK_OUTCOME); - expect(result.current.isLoading).toBe(false); - expect(result.current.hasError).toBe(false); }); - it('sets hasError and clears outcome when the fetch throws', async () => { - setupSelectors(); - mockCall.mockRejectedValue(new Error('fetch failed')); + it('returns the result from the generic hook', () => { + const mockOutcome = { + subscriptionId: 'sub-1', + outcomeStatus: 'pending' as const, + winnerVerificationCode: 'CODE', + }; + mockUseCampaignParticipantOutcome.mockReturnValue({ + outcome: mockOutcome, + isLoading: false, + hasError: false, + }); - const { result, waitForNextUpdate } = renderHook(() => + const { result } = renderHook(() => useOndoCampaignParticipantOutcome(CAMPAIGN_ID), ); - await act(async () => { - await waitForNextUpdate(); - }); - - expect(result.current.outcome).toBeNull(); + expect(result.current.outcome).toEqual(mockOutcome); expect(result.current.isLoading).toBe(false); - expect(result.current.hasError).toBe(true); + expect(result.current.hasError).toBe(false); }); }); diff --git a/app/components/UI/Rewards/hooks/useOndoCampaignParticipantOutcome.ts b/app/components/UI/Rewards/hooks/useOndoCampaignParticipantOutcome.ts index eaabab7f48b..017c62bfc41 100644 --- a/app/components/UI/Rewards/hooks/useOndoCampaignParticipantOutcome.ts +++ b/app/components/UI/Rewards/hooks/useOndoCampaignParticipantOutcome.ts @@ -1,58 +1,19 @@ -import { useCallback, useEffect, useState } from 'react'; -import { useSelector } from 'react-redux'; -import Engine from '../../../../core/Engine'; -import { selectRewardsSubscriptionId } from '../../../../selectors/rewards'; -import { selectCampaignParticipantStatus } from '../../../../reducers/rewards/selectors'; import type { OndoGmCampaignParticipantOutcomeDto } from '../../../../core/Engine/controllers/rewards-controller/types'; +import { + useCampaignParticipantOutcome, + type UseCampaignParticipantOutcomeResult, +} from './useCampaignParticipantOutcome'; -export interface UseOndoCampaignParticipantOutcomeResult { - outcome: OndoGmCampaignParticipantOutcomeDto | null; - isLoading: boolean; - hasError: boolean; -} +export type UseOndoCampaignParticipantOutcomeResult = + UseCampaignParticipantOutcomeResult; export function useOndoCampaignParticipantOutcome( campaignId: string | undefined, ): UseOndoCampaignParticipantOutcomeResult { - const subscriptionId = useSelector(selectRewardsSubscriptionId); - const isOptedIn = - useSelector(selectCampaignParticipantStatus(subscriptionId, campaignId)) - ?.optedIn === true; - const [outcome, setOutcome] = - useState(null); - const [isLoading, setIsLoading] = useState(false); - const [hasError, setHasError] = useState(false); - - const fetchOutcome = useCallback(async (): Promise => { - if (!subscriptionId || !campaignId || !isOptedIn) { - setOutcome(null); - setIsLoading(false); - setHasError(false); - return; - } - - try { - setIsLoading(true); - setHasError(false); - const result = await Engine.controllerMessenger.call( - 'RewardsController:getOndoCampaignParticipantOutcome', - campaignId, - subscriptionId, - ); - setOutcome(result); - } catch { - setOutcome(null); - setHasError(true); - } finally { - setIsLoading(false); - } - }, [campaignId, subscriptionId, isOptedIn]); - - useEffect(() => { - fetchOutcome(); - }, [fetchOutcome]); - - return { outcome, isLoading, hasError }; + return useCampaignParticipantOutcome( + campaignId, + { messengerAction: 'RewardsController:getOndoCampaignParticipantOutcome' }, + ); } export default useOndoCampaignParticipantOutcome; diff --git a/app/components/UI/Rewards/hooks/useOndoOutcomeToast.test.ts b/app/components/UI/Rewards/hooks/useOndoOutcomeToast.test.ts index 7eff188438e..217d284cb64 100644 --- a/app/components/UI/Rewards/hooks/useOndoOutcomeToast.test.ts +++ b/app/components/UI/Rewards/hooks/useOndoOutcomeToast.test.ts @@ -1,554 +1,98 @@ import { renderHook } from '@testing-library/react-hooks'; -import { useContext } from 'react'; -import { useDispatch, useSelector } from 'react-redux'; -import { useFocusEffect, useNavigation } from '@react-navigation/native'; -import { - playSuccessNotification, - playWarningNotification, -} from '../../../../util/haptics'; import { useOndoOutcomeToast } from './useOndoOutcomeToast'; -import { dismissCampaignOutcomeToast } from '../../../../reducers/rewards'; -import { - selectCampaigns, - selectDismissedCampaignOutcomeToasts, -} from '../../../../reducers/rewards/selectors'; -import { selectRewardsSubscriptionId } from '../../../../selectors/rewards'; +import { useCampaignOutcomeToast } from './useCampaignOutcomeToast'; import { useOndoCampaignParticipantOutcome } from './useOndoCampaignParticipantOutcome'; import { CampaignType, - type OndoGmCampaignParticipantOutcomeDto, + type CampaignDto, } from '../../../../core/Engine/controllers/rewards-controller/types'; import Routes from '../../../../constants/navigation/Routes'; -import { - ToastVariants, - ButtonIconVariant, -} from '../../../../component-library/components/Toast/Toast.types'; -import { IconName } from '../../../../component-library/components/Icons/Icon'; - -jest.mock('react', () => ({ - ...jest.requireActual('react'), - useContext: jest.fn(), - useCallback: jest.fn((fn) => fn), - useMemo: jest.fn((fn) => fn()), -})); - -jest.mock('react-redux', () => ({ - useDispatch: jest.fn(), - useSelector: jest.fn(), -})); - -jest.mock('@react-navigation/native', () => ({ - useFocusEffect: jest.fn(), - useNavigation: jest.fn(), -})); - -jest.mock('../../../../util/haptics', () => ({ - playSuccessNotification: jest.fn(), - playWarningNotification: jest.fn(), -})); - -jest.mock('../../../../../locales/i18n', () => ({ - strings: jest.fn((key: string, params?: Record) => { - if (params?.campaignName) return `${key}:${params.campaignName}`; - return key; - }), -})); - -jest.mock('../../../../util/theme', () => { - const actual = jest.requireActual('../../../../util/theme'); - return { - ...actual, - useAppThemeFromContext: () => actual.mockTheme, - }; -}); - -jest.mock('../../../../reducers/rewards', () => ({ - dismissCampaignOutcomeToast: jest.fn(), -})); - -jest.mock('../../../../reducers/rewards/selectors', () => ({ - selectCampaigns: jest.fn(), - selectDismissedCampaignOutcomeToasts: jest.fn(), -})); -jest.mock('../../../../selectors/rewards', () => ({ - selectRewardsSubscriptionId: jest.fn(), +jest.mock('./useCampaignOutcomeToast', () => ({ + useCampaignOutcomeToast: jest.fn(), })); jest.mock('./useOndoCampaignParticipantOutcome', () => ({ useOndoCampaignParticipantOutcome: jest.fn(), })); -const mockDispatch = jest.fn(); -const mockNavigate = jest.fn(); -const mockShowToast = jest.fn(); -const mockCloseToast = jest.fn(); -const mockToastRef = { - current: { showToast: mockShowToast, closeToast: mockCloseToast }, -}; - -const mockUseDispatch = useDispatch as jest.MockedFunction; -const mockUseSelector = useSelector as jest.MockedFunction; -const mockUseFocusEffect = useFocusEffect as jest.MockedFunction< - typeof useFocusEffect ->; -const mockUseNavigation = useNavigation as jest.MockedFunction< - typeof useNavigation ->; -const mockUseOndoCampaignParticipantOutcome = - useOndoCampaignParticipantOutcome as jest.MockedFunction< - typeof useOndoCampaignParticipantOutcome - >; -const mockDismissCampaignOutcomeToast = - dismissCampaignOutcomeToast as jest.MockedFunction< - typeof dismissCampaignOutcomeToast +const mockUseCampaignOutcomeToast = + useCampaignOutcomeToast as jest.MockedFunction< + typeof useCampaignOutcomeToast >; -const SUBSCRIPTION_ID = 'sub-123'; -const CAMPAIGN_ID = 'campaign-456'; -const CAMPAIGN_NAME = 'Ondo Test Campaign'; - -function makeParticipantOutcome( - options: Pick & { - winnerVerificationCode?: string | null; - subscriptionId?: string; - }, -): OndoGmCampaignParticipantOutcomeDto { - return { - subscriptionId: options.subscriptionId ?? SUBSCRIPTION_ID, - outcomeStatus: options.outcomeStatus, - winnerVerificationCode: options.winnerVerificationCode, - }; -} +const CAMPAIGN_ID = 'campaign-123'; +const CAMPAIGN_NAME = 'Ondo Campaign'; -const makeCompletedOndoCampaign = ( - id = CAMPAIGN_ID, - endDate = '2025-01-01T00:00:00Z', -) => ({ +const makeCampaign = (id = CAMPAIGN_ID): CampaignDto => ({ id, name: CAMPAIGN_NAME, type: CampaignType.ONDO_HOLDING, - endDate, - startDate: '2024-01-01T00:00:00Z', + endDate: '2025-01-01', + startDate: '2024-01-01', + termsAndConditions: null, + excludedRegions: [], + details: null, + featured: false, + showUpcomingDate: false, }); -function setupDefaults({ - campaigns = [], - dismissed = {}, - subscriptionId = SUBSCRIPTION_ID, - outcome = null, -}: { - campaigns?: ReturnType[]; - dismissed?: Record; - subscriptionId?: string | null; - outcome?: OndoGmCampaignParticipantOutcomeDto | null; -} = {}) { - mockUseSelector.mockImplementation((selector) => { - if (selector === selectCampaigns) return campaigns; - if (selector === selectDismissedCampaignOutcomeToasts) return dismissed; - if (selector === selectRewardsSubscriptionId) return subscriptionId; - return undefined; - }); - mockUseOndoCampaignParticipantOutcome.mockReturnValue({ - outcome, - isLoading: false, - hasError: false, - }); -} - describe('useOndoOutcomeToast', () => { beforeEach(() => { jest.clearAllMocks(); - mockUseDispatch.mockReturnValue(mockDispatch); - mockUseNavigation.mockReturnValue({ navigate: mockNavigate } as never); - (useContext as jest.Mock).mockReturnValue({ toastRef: mockToastRef }); - mockUseFocusEffect.mockImplementation((cb) => { - cb(); - }); - mockDismissCampaignOutcomeToast.mockReturnValue({ - type: 'rewards/dismissCampaignOutcomeToast', - } as never); - }); - - describe('targetCampaign selection', () => { - it('passes undefined to useOndoCampaignParticipantOutcome when no campaigns', () => { - setupDefaults({ campaigns: [] }); - renderHook(() => useOndoOutcomeToast()); - expect(mockUseOndoCampaignParticipantOutcome).toHaveBeenCalledWith( - undefined, - ); - }); - - it('passes completed ONDO campaign id to useOndoCampaignParticipantOutcome', () => { - const campaign = makeCompletedOndoCampaign(); - setupDefaults({ campaigns: [campaign] }); - renderHook(() => useOndoOutcomeToast()); - expect(mockUseOndoCampaignParticipantOutcome).toHaveBeenCalledWith( - CAMPAIGN_ID, - ); - }); - - it('selects the most recently ended campaign when multiple exist', () => { - const older = makeCompletedOndoCampaign( - 'campaign-old', - '2024-06-01T00:00:00Z', - ); - const newer = makeCompletedOndoCampaign( - 'campaign-new', - '2025-01-01T00:00:00Z', - ); - setupDefaults({ campaigns: [older, newer] }); - renderHook(() => useOndoOutcomeToast()); - expect(mockUseOndoCampaignParticipantOutcome).toHaveBeenCalledWith( - 'campaign-new', - ); - }); - }); - - describe('variant derivation', () => { - it('does not show toast when outcome is null', () => { - setupDefaults({ - campaigns: [makeCompletedOndoCampaign()], - outcome: null, - }); - renderHook(() => useOndoOutcomeToast()); - expect(mockShowToast).not.toHaveBeenCalled(); - }); - - it('derives winner_verify when outcome has verification code and is not finalized', () => { - setupDefaults({ - campaigns: [makeCompletedOndoCampaign()], - outcome: makeParticipantOutcome({ - outcomeStatus: 'pending', - winnerVerificationCode: 'WINNER-XYZ', - }), - }); - renderHook(() => useOndoOutcomeToast()); - expect(mockShowToast).toHaveBeenCalledWith( - expect.objectContaining({ iconName: IconName.Star }), - ); - }); - - it('derives participant_no_winner when outcome is finalized with no verification code', () => { - setupDefaults({ - campaigns: [makeCompletedOndoCampaign()], - outcome: makeParticipantOutcome({ - outcomeStatus: 'finalized', - winnerVerificationCode: null, - }), - }); - renderHook(() => useOndoOutcomeToast()); - expect(mockShowToast).toHaveBeenCalledWith( - expect.objectContaining({ iconName: IconName.Info }), - ); - }); - - it('does not show toast when outcome is finalized with a verification code', () => { - setupDefaults({ - campaigns: [makeCompletedOndoCampaign()], - outcome: makeParticipantOutcome({ - outcomeStatus: 'finalized', - winnerVerificationCode: 'WINNER-XYZ', - }), - }); - renderHook(() => useOndoOutcomeToast()); - expect(mockShowToast).not.toHaveBeenCalled(); - }); - - it('does not show toast when outcome is pending with no verification code', () => { - setupDefaults({ - campaigns: [makeCompletedOndoCampaign()], - outcome: makeParticipantOutcome({ - outcomeStatus: 'pending', - winnerVerificationCode: null, - }), - }); - renderHook(() => useOndoOutcomeToast()); - expect(mockShowToast).not.toHaveBeenCalled(); - }); }); - describe('dismissal check', () => { - it('does not show toast when winner_verify toast was previously dismissed', () => { - const key = `${CAMPAIGN_ID}:${SUBSCRIPTION_ID}:winner_verify`; - setupDefaults({ - campaigns: [makeCompletedOndoCampaign()], - outcome: makeParticipantOutcome({ - outcomeStatus: 'pending', - winnerVerificationCode: 'CODE', - }), - dismissed: { [key]: true }, - }); - renderHook(() => useOndoOutcomeToast()); - expect(mockShowToast).not.toHaveBeenCalled(); - }); - - it('does not show toast when participant_no_winner toast was previously dismissed', () => { - const key = `${CAMPAIGN_ID}:${SUBSCRIPTION_ID}:participant_no_winner`; - setupDefaults({ - campaigns: [makeCompletedOndoCampaign()], - outcome: makeParticipantOutcome({ - outcomeStatus: 'finalized', - winnerVerificationCode: null, - }), - dismissed: { [key]: true }, - }); - renderHook(() => useOndoOutcomeToast()); - expect(mockShowToast).not.toHaveBeenCalled(); - }); - - it('shows toast when a different variant was dismissed', () => { - const key = `${CAMPAIGN_ID}:${SUBSCRIPTION_ID}:participant_no_winner`; - setupDefaults({ - campaigns: [makeCompletedOndoCampaign()], - outcome: makeParticipantOutcome({ - outcomeStatus: 'pending', - winnerVerificationCode: 'CODE', - }), - dismissed: { [key]: true }, - }); - renderHook(() => useOndoOutcomeToast()); - expect(mockShowToast).toHaveBeenCalled(); - }); - }); - - describe('toast configuration', () => { - it('shows winner_verify toast with correct config', () => { - setupDefaults({ - campaigns: [makeCompletedOndoCampaign()], - outcome: makeParticipantOutcome({ - outcomeStatus: 'pending', - winnerVerificationCode: 'CODE', - }), - }); - renderHook(() => useOndoOutcomeToast()); - - expect(mockShowToast).toHaveBeenCalledWith( - expect.objectContaining({ - variant: ToastVariants.Icon, - iconName: IconName.Star, - backgroundColor: 'transparent', - hasNoTimeout: true, - labelOptions: [ - { - label: 'rewards.ondo_outcome_toast.winner_verify.title', - isBold: true, - }, - ], - descriptionOptions: { - description: `rewards.ondo_outcome_toast.winner_verify.description:${CAMPAIGN_NAME}`, - }, - linkButtonOptions: expect.objectContaining({ - label: 'rewards.ondo_outcome_toast.winner_verify.cta', - }), - closeButtonOptions: expect.objectContaining({ - variant: ButtonIconVariant.Icon, - iconName: IconName.Close, - }), - }), - ); - }); - - it('shows participant_no_winner toast with correct config', () => { - setupDefaults({ - campaigns: [makeCompletedOndoCampaign()], - outcome: makeParticipantOutcome({ - outcomeStatus: 'finalized', - winnerVerificationCode: null, - }), - }); - renderHook(() => useOndoOutcomeToast()); - - expect(mockShowToast).toHaveBeenCalledWith( - expect.objectContaining({ - variant: ToastVariants.Icon, - iconName: IconName.Info, - backgroundColor: 'transparent', - hasNoTimeout: true, - labelOptions: [ - { - label: 'rewards.ondo_outcome_toast.participant_no_winner.title', - isBold: true, - }, - ], - descriptionOptions: { - description: `rewards.ondo_outcome_toast.participant_no_winner.description:${CAMPAIGN_NAME}`, - }, - linkButtonOptions: expect.objectContaining({ - label: 'rewards.ondo_outcome_toast.participant_no_winner.cta', - }), - }), - ); - }); - - it('fires Success haptic for winner_verify', () => { - setupDefaults({ - campaigns: [makeCompletedOndoCampaign()], - outcome: makeParticipantOutcome({ - outcomeStatus: 'pending', - winnerVerificationCode: 'CODE', - }), - }); - renderHook(() => useOndoOutcomeToast()); - expect(playSuccessNotification).toHaveBeenCalled(); - }); - - it('fires Warning haptic for participant_no_winner', () => { - setupDefaults({ - campaigns: [makeCompletedOndoCampaign()], - outcome: makeParticipantOutcome({ - outcomeStatus: 'finalized', - winnerVerificationCode: null, - }), - }); - renderHook(() => useOndoOutcomeToast()); - expect(playWarningNotification).toHaveBeenCalled(); - }); + it('calls useCampaignOutcomeToast with ONDO_HOLDING campaign type', () => { + renderHook(() => useOndoOutcomeToast()); + expect(mockUseCampaignOutcomeToast).toHaveBeenCalledWith( + expect.objectContaining({ + campaignType: CampaignType.ONDO_HOLDING, + }), + ); }); - describe('cleanup on blur', () => { - it('calls closeToast in the cleanup function when screen blurs', () => { - let cleanupFn: (() => void) | undefined; - mockUseFocusEffect.mockImplementation((cb) => { - const cleanup = cb(); - if (typeof cleanup === 'function') cleanupFn = cleanup; - }); - - setupDefaults({ - campaigns: [makeCompletedOndoCampaign()], - outcome: makeParticipantOutcome({ - outcomeStatus: 'pending', - winnerVerificationCode: 'CODE', - }), - }); - renderHook(() => useOndoOutcomeToast()); - - expect(cleanupFn).toBeDefined(); - cleanupFn?.(); - expect(mockCloseToast).toHaveBeenCalledTimes(1); - }); - - it('does not return a cleanup function when variant is null', () => { - let cleanupFn: (() => void) | undefined; - mockUseFocusEffect.mockImplementation((cb) => { - const result = cb(); - if (typeof result === 'function') cleanupFn = result; - }); - - setupDefaults({ - campaigns: [makeCompletedOndoCampaign()], - outcome: null, - }); - renderHook(() => useOndoOutcomeToast()); - - expect(cleanupFn).toBeUndefined(); - expect(mockCloseToast).not.toHaveBeenCalled(); - }); + it('passes useOndoCampaignParticipantOutcome as the useOutcome function', () => { + renderHook(() => useOndoOutcomeToast()); + expect(mockUseCampaignOutcomeToast).toHaveBeenCalledWith( + expect.objectContaining({ + useOutcome: useOndoCampaignParticipantOutcome, + }), + ); }); - describe('handleDismiss', () => { - it('dispatches dismissCampaignOutcomeToast and closes toast when close button is pressed', () => { - setupDefaults({ - campaigns: [makeCompletedOndoCampaign()], - outcome: makeParticipantOutcome({ - outcomeStatus: 'pending', - winnerVerificationCode: 'CODE', - }), - }); - renderHook(() => useOndoOutcomeToast()); - - const closeButtonOptions = - mockShowToast.mock.calls[0][0].closeButtonOptions; - closeButtonOptions.onPress(); - - expect(mockDispatch).toHaveBeenCalledWith( - mockDismissCampaignOutcomeToast.mock.results[0]?.value, - ); - expect(mockDismissCampaignOutcomeToast).toHaveBeenCalledWith({ - campaignId: CAMPAIGN_ID, - subscriptionId: SUBSCRIPTION_ID, - variant: 'winner_verify', - }); - expect(mockCloseToast).toHaveBeenCalled(); + it('getWinnerNavigation returns ONDO winning view route with campaignId and campaignName', () => { + renderHook(() => useOndoOutcomeToast()); + const { getWinnerNavigation } = + mockUseCampaignOutcomeToast.mock.calls[0][0]; + const nav = getWinnerNavigation(makeCampaign()); + expect(nav).toEqual({ + route: Routes.REWARDS_ONDO_CAMPAIGN_WINNING_VIEW, + params: { campaignId: CAMPAIGN_ID, campaignName: CAMPAIGN_NAME }, }); }); - describe('handleCta', () => { - it('navigates to winning view and dismisses for winner_verify', () => { - setupDefaults({ - campaigns: [makeCompletedOndoCampaign()], - outcome: makeParticipantOutcome({ - outcomeStatus: 'pending', - winnerVerificationCode: 'CODE', - }), - }); - renderHook(() => useOndoOutcomeToast()); - - const linkButtonOptions = - mockShowToast.mock.calls[0][0].linkButtonOptions; - linkButtonOptions.onPress(); - - expect(mockNavigate).toHaveBeenCalledWith( - Routes.REWARDS_ONDO_CAMPAIGN_WINNING_VIEW, - { campaignId: CAMPAIGN_ID }, - ); - expect(mockDismissCampaignOutcomeToast).toHaveBeenCalledWith({ - campaignId: CAMPAIGN_ID, - subscriptionId: SUBSCRIPTION_ID, - variant: 'winner_verify', - }); + it('getWinnerNavigation uses empty string for campaignName when name is null', () => { + renderHook(() => useOndoOutcomeToast()); + const { getWinnerNavigation } = + mockUseCampaignOutcomeToast.mock.calls[0][0]; + const nav = getWinnerNavigation({ + ...makeCampaign(), + name: null as unknown as string, }); - - it('navigates to campaign details view and dismisses for participant_no_winner', () => { - setupDefaults({ - campaigns: [makeCompletedOndoCampaign()], - outcome: makeParticipantOutcome({ - outcomeStatus: 'finalized', - winnerVerificationCode: null, - }), - }); - renderHook(() => useOndoOutcomeToast()); - - const linkButtonOptions = - mockShowToast.mock.calls[0][0].linkButtonOptions; - linkButtonOptions.onPress(); - - expect(mockNavigate).toHaveBeenCalledWith( - Routes.REWARDS_ONDO_CAMPAIGN_DETAILS_VIEW, - { campaignId: CAMPAIGN_ID }, - ); - expect(mockDismissCampaignOutcomeToast).toHaveBeenCalledWith({ - campaignId: CAMPAIGN_ID, - subscriptionId: SUBSCRIPTION_ID, - variant: 'participant_no_winner', - }); + expect(nav.params).toEqual({ + campaignId: CAMPAIGN_ID, + campaignName: '', }); }); - describe('edge cases', () => { - it('does not show toast when subscriptionId is missing', () => { - setupDefaults({ - campaigns: [makeCompletedOndoCampaign()], - outcome: makeParticipantOutcome({ - outcomeStatus: 'pending', - winnerVerificationCode: 'CODE', - }), - subscriptionId: null, - }); - renderHook(() => useOndoOutcomeToast()); - expect(mockShowToast).not.toHaveBeenCalled(); - }); - - it('handles null toastRef gracefully', () => { - (useContext as jest.Mock).mockReturnValue({ toastRef: null }); - setupDefaults({ - campaigns: [makeCompletedOndoCampaign()], - outcome: makeParticipantOutcome({ - outcomeStatus: 'pending', - winnerVerificationCode: 'CODE', - }), - }); - expect(() => renderHook(() => useOndoOutcomeToast())).not.toThrow(); + it('getNonWinnerNavigation returns ONDO campaign details route', () => { + renderHook(() => useOndoOutcomeToast()); + const { getNonWinnerNavigation } = + mockUseCampaignOutcomeToast.mock.calls[0][0]; + const nav = getNonWinnerNavigation(makeCampaign()); + expect(nav).toEqual({ + route: Routes.REWARDS_ONDO_CAMPAIGN_DETAILS_VIEW, + params: { campaignId: CAMPAIGN_ID }, }); }); }); diff --git a/app/components/UI/Rewards/hooks/useOndoOutcomeToast.ts b/app/components/UI/Rewards/hooks/useOndoOutcomeToast.ts index 87c295cf1f7..2a736f931c8 100644 --- a/app/components/UI/Rewards/hooks/useOndoOutcomeToast.ts +++ b/app/components/UI/Rewards/hooks/useOndoOutcomeToast.ts @@ -1,160 +1,21 @@ -import { useCallback, useContext, useMemo } from 'react'; -import { useFocusEffect, useNavigation } from '@react-navigation/native'; -import { useDispatch, useSelector } from 'react-redux'; -import { - playSuccessNotification, - playWarningNotification, -} from '../../../../util/haptics'; -import Routes from '../../../../constants/navigation/Routes'; -import { strings } from '../../../../../locales/i18n'; -import { ToastContext } from '../../../../component-library/components/Toast'; -import { - ButtonIconVariant, - ToastVariants, -} from '../../../../component-library/components/Toast/Toast.types'; -import { IconName } from '../../../../component-library/components/Icons/Icon'; -import { useAppThemeFromContext } from '../../../../util/theme'; import { CampaignType } from '../../../../core/Engine/controllers/rewards-controller/types'; -import { getCampaignStatus } from '../components/Campaigns/CampaignTile.utils'; -import { dismissCampaignOutcomeToast } from '../../../../reducers/rewards'; -import { - selectCampaigns, - selectDismissedCampaignOutcomeToasts, -} from '../../../../reducers/rewards/selectors'; -import { selectRewardsSubscriptionId } from '../../../../selectors/rewards'; +import Routes from '../../../../constants/navigation/Routes'; +import { useCampaignOutcomeToast } from './useCampaignOutcomeToast'; import { useOndoCampaignParticipantOutcome } from './useOndoCampaignParticipantOutcome'; -export type OutcomeToastVariant = 'winner_verify' | 'participant_no_winner'; - export function useOndoOutcomeToast(): void { - const dispatch = useDispatch(); - const { toastRef } = useContext(ToastContext); - const theme = useAppThemeFromContext(); - const navigation = useNavigation(); - - const subscriptionId = useSelector(selectRewardsSubscriptionId); - const campaigns = useSelector(selectCampaigns); - const dismissed = useSelector(selectDismissedCampaignOutcomeToasts); - - const targetCampaign = useMemo(() => { - const completed = campaigns - .filter( - (c) => - c.type === CampaignType.ONDO_HOLDING && - getCampaignStatus(c) === 'complete', - ) - .sort( - (a, b) => new Date(b.endDate).getTime() - new Date(a.endDate).getTime(), - ); - return completed[0] ?? null; - }, [campaigns]); - - const { outcome } = useOndoCampaignParticipantOutcome(targetCampaign?.id); - - const variant = useMemo((): OutcomeToastVariant | null => { - if (!outcome) return null; - if ( - outcome.winnerVerificationCode && - outcome.outcomeStatus !== 'finalized' - ) { - return 'winner_verify'; - } - if ( - outcome.outcomeStatus === 'finalized' && - !outcome.winnerVerificationCode - ) { - return 'participant_no_winner'; - } - return null; - }, [outcome]); - - const isDismissed = useMemo(() => { - if (!variant || !targetCampaign || !subscriptionId) return true; - const key = `${targetCampaign.id}:${subscriptionId}:${variant}`; - return dismissed[key] === true; - }, [variant, targetCampaign, subscriptionId, dismissed]); - - const handleDismiss = useCallback(() => { - if (!variant || !targetCampaign || !subscriptionId) return; - dispatch( - dismissCampaignOutcomeToast({ - campaignId: targetCampaign.id, - subscriptionId, - variant, - }), - ); - toastRef?.current?.closeToast(); - }, [variant, targetCampaign, subscriptionId, dispatch, toastRef]); - - const handleCta = useCallback(() => { - if (!targetCampaign || !variant) return; - handleDismiss(); - if (variant === 'winner_verify') { - navigation.navigate(Routes.REWARDS_ONDO_CAMPAIGN_WINNING_VIEW, { - campaignId: targetCampaign.id, - }); - } else { - navigation.navigate(Routes.REWARDS_ONDO_CAMPAIGN_DETAILS_VIEW, { - campaignId: targetCampaign.id, - }); - } - }, [variant, targetCampaign, handleDismiss, navigation]); - - useFocusEffect( - useCallback(() => { - if (!variant || isDismissed || !targetCampaign) return; - - const isWinner = variant === 'winner_verify'; - toastRef?.current?.showToast({ - variant: ToastVariants.Icon, - iconName: isWinner ? IconName.Star : IconName.Info, - iconColor: isWinner - ? theme.colors.warning.default - : theme.colors.success.default, - backgroundColor: 'transparent', - hasNoTimeout: true, - labelOptions: [ - { - label: strings(`rewards.ondo_outcome_toast.${variant}.title`), - isBold: true, - }, - ], - descriptionOptions: { - description: strings( - `rewards.ondo_outcome_toast.${variant}.description`, - { campaignName: targetCampaign.name }, - ), - }, - linkButtonOptions: { - label: strings(`rewards.ondo_outcome_toast.${variant}.cta`), - onPress: handleCta, - }, - closeButtonOptions: { - variant: ButtonIconVariant.Icon, - iconName: IconName.Close, - onPress: handleDismiss, - }, - }); - if (isWinner) { - playSuccessNotification(); - } else { - playWarningNotification(); - } - - return () => { - toastRef?.current?.closeToast(); - }; - }, [ - variant, - isDismissed, - targetCampaign, - toastRef, - theme.colors.warning.default, - theme.colors.success.default, - handleCta, - handleDismiss, - ]), - ); + useCampaignOutcomeToast({ + campaignType: CampaignType.ONDO_HOLDING, + useOutcome: useOndoCampaignParticipantOutcome, + getWinnerNavigation: (campaign) => ({ + route: Routes.REWARDS_ONDO_CAMPAIGN_WINNING_VIEW, + params: { campaignId: campaign.id, campaignName: campaign.name ?? '' }, + }), + getNonWinnerNavigation: (campaign) => ({ + route: Routes.REWARDS_ONDO_CAMPAIGN_DETAILS_VIEW, + params: { campaignId: campaign.id }, + }), + }); } export default useOndoOutcomeToast; diff --git a/app/components/UI/Rewards/hooks/useOptInToCampaign.test.ts b/app/components/UI/Rewards/hooks/useOptInToCampaign.test.ts index c94087723b0..e97add67a7e 100644 --- a/app/components/UI/Rewards/hooks/useOptInToCampaign.test.ts +++ b/app/components/UI/Rewards/hooks/useOptInToCampaign.test.ts @@ -1,10 +1,12 @@ import { renderHook, act } from '@testing-library/react-hooks'; -import { useSelector } from 'react-redux'; +import { useDispatch, useSelector } from 'react-redux'; import { useOptInToCampaign } from './useOptInToCampaign'; import Engine from '../../../../core/Engine'; import { selectRewardsSubscriptionId } from '../../../../selectors/rewards'; +import { setCampaignParticipantStatus } from '../../../../reducers/rewards'; jest.mock('react-redux', () => ({ + useDispatch: jest.fn(), useSelector: jest.fn(), })); @@ -16,10 +18,22 @@ jest.mock('../../../../selectors/rewards', () => ({ selectRewardsSubscriptionId: jest.fn(), })); +jest.mock('../../../../reducers/rewards', () => ({ + setCampaignParticipantStatus: jest.fn((payload) => ({ + type: 'rewards/setCampaignParticipantStatus', + payload, + })), +})); + const mockCall = Engine.controllerMessenger.call as jest.MockedFunction< typeof Engine.controllerMessenger.call >; const mockUseSelector = useSelector as jest.MockedFunction; +const mockUseDispatch = useDispatch as jest.MockedFunction; +const mockSetCampaignParticipantStatus = + setCampaignParticipantStatus as unknown as jest.MockedFunction< + typeof setCampaignParticipantStatus + >; const SUB_ID = 'sub-123'; const CAMPAIGN_ID = 'camp-456'; @@ -33,8 +47,11 @@ function setupSelectors(subscriptionId: string | null) { } describe('useOptInToCampaign', () => { + const mockDispatch = jest.fn(); + beforeEach(() => { jest.clearAllMocks(); + mockUseDispatch.mockReturnValue(mockDispatch); }); it('returns null when subscriptionId is missing', async () => { @@ -63,6 +80,18 @@ describe('useOptInToCampaign', () => { CAMPAIGN_ID, SUB_ID, ); + expect(mockDispatch).toHaveBeenCalledWith( + setCampaignParticipantStatus({ + subscriptionId: SUB_ID, + campaignId: CAMPAIGN_ID, + status: STATUS, + }), + ); + expect(mockSetCampaignParticipantStatus).toHaveBeenCalledWith({ + subscriptionId: SUB_ID, + campaignId: CAMPAIGN_ID, + status: STATUS, + }); expect(returnValue).toEqual(STATUS); expect(result.current.isOptingIn).toBe(false); expect(result.current.optInError).toBeUndefined(); diff --git a/app/components/UI/Rewards/hooks/useOptInToCampaign.ts b/app/components/UI/Rewards/hooks/useOptInToCampaign.ts index b3852646997..a0533e7889f 100644 --- a/app/components/UI/Rewards/hooks/useOptInToCampaign.ts +++ b/app/components/UI/Rewards/hooks/useOptInToCampaign.ts @@ -1,7 +1,8 @@ import { useCallback, useState } from 'react'; -import { useSelector } from 'react-redux'; +import { useDispatch, useSelector } from 'react-redux'; import Engine from '../../../../core/Engine'; import { selectRewardsSubscriptionId } from '../../../../selectors/rewards'; +import { setCampaignParticipantStatus } from '../../../../reducers/rewards'; import type { CampaignParticipantStatusDto } from '../../../../core/Engine/controllers/rewards-controller/types'; export interface UseOptInToCampaignResult { @@ -22,6 +23,7 @@ export interface UseOptInToCampaignResult { */ export const useOptInToCampaign = (): UseOptInToCampaignResult => { const subscriptionId = useSelector(selectRewardsSubscriptionId); + const dispatch = useDispatch(); const [isOptingIn, setIsOptingIn] = useState(false); const [optInError, setOptInError] = useState(undefined); @@ -36,11 +38,19 @@ export const useOptInToCampaign = (): UseOptInToCampaignResult => { try { setIsOptingIn(true); setOptInError(undefined); - return await Engine.controllerMessenger.call( + const result = await Engine.controllerMessenger.call( 'RewardsController:optInToCampaign', campaignId, subscriptionId, ); + dispatch( + setCampaignParticipantStatus({ + subscriptionId, + campaignId, + status: result, + }), + ); + return result; } catch (error) { const message = error instanceof Error ? error.message : 'Opt-in failed'; @@ -50,7 +60,7 @@ export const useOptInToCampaign = (): UseOptInToCampaignResult => { setIsOptingIn(false); } }, - [subscriptionId], + [dispatch, subscriptionId], ); const clearOptInError = useCallback(() => setOptInError(undefined), []); diff --git a/app/components/UI/Rewards/hooks/usePerpsTradingCampaignEndedOutcomeToast.test.ts b/app/components/UI/Rewards/hooks/usePerpsTradingCampaignEndedOutcomeToast.test.ts new file mode 100644 index 00000000000..44c7d775a5c --- /dev/null +++ b/app/components/UI/Rewards/hooks/usePerpsTradingCampaignEndedOutcomeToast.test.ts @@ -0,0 +1,98 @@ +import { renderHook } from '@testing-library/react-hooks'; +import { usePerpsTradingCampaignEndedOutcomeToast } from './usePerpsTradingCampaignEndedOutcomeToast'; +import { useCampaignOutcomeToast } from './useCampaignOutcomeToast'; +import { usePerpsTradingCampaignParticipantOutcome } from './usePerpsTradingCampaignParticipantOutcome'; +import { + CampaignType, + type CampaignDto, +} from '../../../../core/Engine/controllers/rewards-controller/types'; +import Routes from '../../../../constants/navigation/Routes'; + +jest.mock('./useCampaignOutcomeToast', () => ({ + useCampaignOutcomeToast: jest.fn(), +})); + +jest.mock('./usePerpsTradingCampaignParticipantOutcome', () => ({ + usePerpsTradingCampaignParticipantOutcome: jest.fn(), +})); + +const mockUseCampaignOutcomeToast = + useCampaignOutcomeToast as jest.MockedFunction< + typeof useCampaignOutcomeToast + >; + +const CAMPAIGN_ID = 'campaign-xyz'; +const CAMPAIGN_NAME = 'Perps Campaign'; + +const makeCampaign = (id = CAMPAIGN_ID, name = CAMPAIGN_NAME): CampaignDto => ({ + id, + name, + type: CampaignType.PERPS_TRADING, + endDate: '2025-01-01', + startDate: '2024-01-01', + termsAndConditions: null, + excludedRegions: [], + details: null, + featured: false, + showUpcomingDate: false, +}); + +describe('usePerpsTradingCampaignEndedOutcomeToast', () => { + beforeEach(() => { + jest.clearAllMocks(); + }); + + it('calls useCampaignOutcomeToast with PERPS_TRADING campaign type', () => { + renderHook(() => usePerpsTradingCampaignEndedOutcomeToast()); + expect(mockUseCampaignOutcomeToast).toHaveBeenCalledWith( + expect.objectContaining({ + campaignType: CampaignType.PERPS_TRADING, + }), + ); + }); + + it('passes usePerpsTradingCampaignParticipantOutcome as the useOutcome function', () => { + renderHook(() => usePerpsTradingCampaignEndedOutcomeToast()); + expect(mockUseCampaignOutcomeToast).toHaveBeenCalledWith( + expect.objectContaining({ + useOutcome: usePerpsTradingCampaignParticipantOutcome, + }), + ); + }); + + it('getWinnerNavigation returns Perps winning view route with campaignId and campaignName', () => { + renderHook(() => usePerpsTradingCampaignEndedOutcomeToast()); + const { getWinnerNavigation } = + mockUseCampaignOutcomeToast.mock.calls[0][0]; + const nav = getWinnerNavigation(makeCampaign()); + expect(nav).toEqual({ + route: Routes.REWARDS_PERPS_TRADING_CAMPAIGN_WINNING_VIEW, + params: { campaignId: CAMPAIGN_ID, campaignName: CAMPAIGN_NAME }, + }); + }); + + it('getWinnerNavigation uses empty string for campaignName when name is null', () => { + renderHook(() => usePerpsTradingCampaignEndedOutcomeToast()); + const { getWinnerNavigation } = + mockUseCampaignOutcomeToast.mock.calls[0][0]; + const nav = getWinnerNavigation({ + ...makeCampaign(), + name: null as unknown as string, + }); + expect(nav.params).toEqual({ + campaignId: CAMPAIGN_ID, + campaignName: '', + }); + }); + + it('getNonWinnerNavigation returns Perps details view route', () => { + renderHook(() => usePerpsTradingCampaignEndedOutcomeToast()); + const { getNonWinnerNavigation } = + mockUseCampaignOutcomeToast.mock.calls[0][0]; + const nav = getNonWinnerNavigation(makeCampaign()); + expect(nav).toEqual({ + route: Routes.REWARDS_PERPS_TRADING_CAMPAIGN_DETAILS_VIEW, + params: { campaignId: CAMPAIGN_ID }, + }); + }); +}); diff --git a/app/components/UI/Rewards/hooks/usePerpsTradingCampaignEndedOutcomeToast.ts b/app/components/UI/Rewards/hooks/usePerpsTradingCampaignEndedOutcomeToast.ts new file mode 100644 index 00000000000..ae99229b1ba --- /dev/null +++ b/app/components/UI/Rewards/hooks/usePerpsTradingCampaignEndedOutcomeToast.ts @@ -0,0 +1,21 @@ +import { CampaignType } from '../../../../core/Engine/controllers/rewards-controller/types'; +import Routes from '../../../../constants/navigation/Routes'; +import { useCampaignOutcomeToast } from './useCampaignOutcomeToast'; +import { usePerpsTradingCampaignParticipantOutcome } from './usePerpsTradingCampaignParticipantOutcome'; + +export function usePerpsTradingCampaignEndedOutcomeToast(): void { + useCampaignOutcomeToast({ + campaignType: CampaignType.PERPS_TRADING, + useOutcome: usePerpsTradingCampaignParticipantOutcome, + getWinnerNavigation: (campaign) => ({ + route: Routes.REWARDS_PERPS_TRADING_CAMPAIGN_WINNING_VIEW, + params: { campaignId: campaign.id, campaignName: campaign.name ?? '' }, + }), + getNonWinnerNavigation: (campaign) => ({ + route: Routes.REWARDS_PERPS_TRADING_CAMPAIGN_DETAILS_VIEW, + params: { campaignId: campaign.id }, + }), + }); +} + +export default usePerpsTradingCampaignEndedOutcomeToast; diff --git a/app/components/UI/Rewards/hooks/usePerpsTradingCampaignParticipantOutcome.test.ts b/app/components/UI/Rewards/hooks/usePerpsTradingCampaignParticipantOutcome.test.ts new file mode 100644 index 00000000000..35795726f9f --- /dev/null +++ b/app/components/UI/Rewards/hooks/usePerpsTradingCampaignParticipantOutcome.test.ts @@ -0,0 +1,68 @@ +import { renderHook } from '@testing-library/react-hooks'; +import { usePerpsTradingCampaignParticipantOutcome } from './usePerpsTradingCampaignParticipantOutcome'; +import { useCampaignParticipantOutcome } from './useCampaignParticipantOutcome'; + +jest.mock('./useCampaignParticipantOutcome', () => ({ + useCampaignParticipantOutcome: jest.fn(), +})); + +const mockUseCampaignParticipantOutcome = + useCampaignParticipantOutcome as jest.MockedFunction< + typeof useCampaignParticipantOutcome + >; + +const CAMPAIGN_ID = 'campaign-xyz'; + +describe('usePerpsTradingCampaignParticipantOutcome', () => { + beforeEach(() => { + jest.clearAllMocks(); + mockUseCampaignParticipantOutcome.mockReturnValue({ + outcome: null, + isLoading: false, + hasError: false, + }); + }); + + it('delegates to useCampaignParticipantOutcome with the Perps messenger action', () => { + renderHook(() => usePerpsTradingCampaignParticipantOutcome(CAMPAIGN_ID)); + + expect(mockUseCampaignParticipantOutcome).toHaveBeenCalledWith( + CAMPAIGN_ID, + { + messengerAction: + 'RewardsController:getPerpsTradingCampaignParticipantOutcome', + }, + ); + }); + + it('passes undefined campaignId through to the generic hook', () => { + renderHook(() => usePerpsTradingCampaignParticipantOutcome(undefined)); + + expect(mockUseCampaignParticipantOutcome).toHaveBeenCalledWith(undefined, { + messengerAction: + 'RewardsController:getPerpsTradingCampaignParticipantOutcome', + }); + }); + + it('returns the result from the generic hook', () => { + const mockOutcome = { + subscriptionId: 'sub-1', + outcomeStatus: 'pending' as const, + winnerVerificationCode: 'CODE', + rank: 3, + }; + mockUseCampaignParticipantOutcome.mockReturnValue({ + outcome: mockOutcome, + isLoading: false, + hasError: false, + }); + + const { result } = renderHook(() => + usePerpsTradingCampaignParticipantOutcome(CAMPAIGN_ID), + ); + + expect(result.current.outcome).toEqual(mockOutcome); + expect(result.current.isLoading).toBe(false); + expect(result.current.hasError).toBe(false); + }); +}); diff --git a/app/components/UI/Rewards/hooks/usePerpsTradingCampaignParticipantOutcome.ts b/app/components/UI/Rewards/hooks/usePerpsTradingCampaignParticipantOutcome.ts new file mode 100644 index 00000000000..255885f2823 --- /dev/null +++ b/app/components/UI/Rewards/hooks/usePerpsTradingCampaignParticipantOutcome.ts @@ -0,0 +1,22 @@ +import type { PerpsTradingCampaignParticipantOutcomeDto } from '../../../../core/Engine/controllers/rewards-controller/types'; +import { + useCampaignParticipantOutcome, + type UseCampaignParticipantOutcomeResult, +} from './useCampaignParticipantOutcome'; + +export type UsePerpsTradingCampaignParticipantOutcomeResult = + UseCampaignParticipantOutcomeResult; + +export function usePerpsTradingCampaignParticipantOutcome( + campaignId: string | undefined, +): UsePerpsTradingCampaignParticipantOutcomeResult { + return useCampaignParticipantOutcome( + campaignId, + { + messengerAction: + 'RewardsController:getPerpsTradingCampaignParticipantOutcome', + }, + ); +} + +export default usePerpsTradingCampaignParticipantOutcome; diff --git a/app/components/UI/Rewards/hooks/useRewardsToast.test.tsx b/app/components/UI/Rewards/hooks/useRewardsToast.test.tsx index 4f3ec45b6c7..aa29b8cbfb2 100644 --- a/app/components/UI/Rewards/hooks/useRewardsToast.test.tsx +++ b/app/components/UI/Rewards/hooks/useRewardsToast.test.tsx @@ -390,6 +390,68 @@ describe('useRewardsToast', () => { expect(mockCloseToast).toHaveBeenCalledTimes(1); }); + + it('returns outcomeWinner configuration with CTA and close handlers', () => { + const { result } = renderHook(() => useRewardsToast()); + const onCta = jest.fn(); + const onClose = jest.fn(); + const config = result.current.RewardsToastOptions.outcomeWinner({ + title: 'Winner title', + description: 'Winner body', + ctaLabel: 'Next', + onCtaPress: onCta, + onClosePress: onClose, + }); + + expect(config).toMatchObject({ + variant: ToastVariants.Plain, + hasNoTimeout: true, + hapticsType: NotificationMoment.Success, + descriptionOptions: { description: 'Winner body' }, + linkButtonOptions: { label: 'Next' }, + }); + expect(config.labelOptions).toEqual([ + { label: 'Winner title', isBold: true }, + ]); + expect(config.startAccessory).toBeDefined(); + const { getByTestId } = render(config.startAccessory as ReactElement); + expect(getByTestId('rewards-nudge-start-accessory-box')).toBeDefined(); + config.linkButtonOptions?.onPress?.(); + config.closeButtonOptions?.onPress?.(); + expect(onCta).toHaveBeenCalledTimes(1); + expect(onClose).toHaveBeenCalledTimes(1); + }); + + it('returns outcomeNonWinner configuration with CTA and close handlers', () => { + const { result } = renderHook(() => useRewardsToast()); + const onCta = jest.fn(); + const onClose = jest.fn(); + const config = result.current.RewardsToastOptions.outcomeNonWinner({ + title: 'Thanks title', + description: 'Thanks body', + ctaLabel: 'Done', + onCtaPress: onCta, + onClosePress: onClose, + }); + + expect(config).toMatchObject({ + variant: ToastVariants.Icon, + iconName: IconName.Confirmation, + iconColor: mockTheme.colors.success.default, + backgroundColor: 'transparent', + hasNoTimeout: true, + hapticsType: NotificationMoment.Warning, + descriptionOptions: { description: 'Thanks body' }, + linkButtonOptions: { label: 'Done' }, + }); + expect(config.labelOptions).toEqual([ + { label: 'Thanks title', isBold: true }, + ]); + config.linkButtonOptions?.onPress?.(); + config.closeButtonOptions?.onPress?.(); + expect(onCta).toHaveBeenCalledTimes(1); + expect(onClose).toHaveBeenCalledTimes(1); + }); }); describe('edge cases and error handling', () => { diff --git a/app/components/UI/Rewards/hooks/useRewardsToast.tsx b/app/components/UI/Rewards/hooks/useRewardsToast.tsx index db14b667bd8..48fa2bfe57c 100644 --- a/app/components/UI/Rewards/hooks/useRewardsToast.tsx +++ b/app/components/UI/Rewards/hooks/useRewardsToast.tsx @@ -1,5 +1,6 @@ import React, { useCallback, useContext, useMemo } from 'react'; import { ActivityIndicator } from 'react-native'; +import { Box } from '@metamask/design-system-react-native'; import { ToastContext } from '../../../../component-library/components/Toast'; import { ButtonIconVariant, @@ -18,12 +19,20 @@ import { } from '../../../../util/haptics'; import { strings } from '../../../../../locales/i18n'; import RewardsNotificationIcon from '../../../../images/rewards/notification.svg'; -import { Box } from '@metamask/design-system-react-native'; +import RewardsTrophyIcon from '../../../../images/rewards/trophy.svg'; export type RewardsToastOptions = ToastOptions & { hapticsType: HapticNotificationMoment; }; +export interface OutcomeCtaToastParams { + title: string; + description: string; + ctaLabel: string; + onCtaPress: () => void; + onClosePress: () => void; +} + export interface RewardsToastConfig { success: (title: string, subtitle?: string) => RewardsToastOptions; error: (title: string, subtitle?: string) => RewardsToastOptions; @@ -32,6 +41,8 @@ export interface RewardsToastConfig { enableNotificationsNudge: ( linkButtonOptions: ToastLinkButtonOptions, ) => RewardsToastOptions; + outcomeWinner: (params: OutcomeCtaToastParams) => RewardsToastOptions; + outcomeNonWinner: (params: OutcomeCtaToastParams) => RewardsToastOptions; } const getRewardsToastLabels = (title: string): ToastLabelOptions => { @@ -183,6 +194,64 @@ const useRewardsToast = (): { }, }, }), + outcomeWinner: ({ + title, + description, + ctaLabel, + onCtaPress, + onClosePress, + }: OutcomeCtaToastParams) => ({ + ...(REWARDS_TOASTS_DEFAULT_OPTIONS as RewardsToastOptions), + variant: ToastVariants.Plain, + hasNoTimeout: true, + hapticsType: NotificationMoment.Success, + startAccessory: ( + + + + ), + labelOptions: getRewardsToastLabels(title), + descriptionOptions: { description }, + linkButtonOptions: { + label: ctaLabel, + onPress: onCtaPress, + }, + closeButtonOptions: { + variant: ButtonIconVariant.Icon, + iconName: IconName.Close, + onPress: onClosePress, + }, + }), + outcomeNonWinner: ({ + title, + description, + ctaLabel, + onCtaPress, + onClosePress, + }: OutcomeCtaToastParams) => ({ + variant: ToastVariants.Icon, + iconName: IconName.Confirmation, + iconColor: theme.colors.success.default, + backgroundColor: 'transparent', + hasNoTimeout: true, + hapticsType: NotificationMoment.Warning, + labelOptions: getRewardsToastLabels(title), + descriptionOptions: { description }, + linkButtonOptions: { + label: ctaLabel, + onPress: onCtaPress, + }, + closeButtonOptions: { + variant: ButtonIconVariant.Icon, + iconName: IconName.Close, + onPress: onClosePress, + }, + }), }), [ theme.colors.success.default, diff --git a/app/components/UI/Rewards/utils.ts b/app/components/UI/Rewards/utils.ts index cf902295145..fff1df35443 100644 --- a/app/components/UI/Rewards/utils.ts +++ b/app/components/UI/Rewards/utils.ts @@ -106,6 +106,7 @@ export enum RewardsMetricsButtons { VISIT_APP_STORE = 'visit_app_store', BUY_MUSD = 'buy_musd', SWAP_TO_MUSD = 'swap_to_musd', + COPY_WINNER_VERIFICATION_CODE = 'copy_winner_verification_code', } export const deriveAccountMetricProps = (account?: InternalAccount) => { diff --git a/app/constants/navigation/Routes.ts b/app/constants/navigation/Routes.ts index 323016d1752..c3851369f74 100644 --- a/app/constants/navigation/Routes.ts +++ b/app/constants/navigation/Routes.ts @@ -111,6 +111,8 @@ const Routes = { REWARDS_SEASON_ONE_CAMPAIGN_DETAILS_VIEW: 'RewardsSeasonOneCampaignDetails', REWARDS_CAMPAIGN_MECHANICS: 'RewardsCampaignMechanics', REWARDS_ONDO_CAMPAIGN_LEADERBOARD: 'RewardsOndoCampaignLeaderboard', + REWARDS_PERPS_TRADING_CAMPAIGN_WINNING_VIEW: + 'RewardsPerpsTradingCampaignWinning', REWARDS_ONDO_CAMPAIGN_RWA_ASSET_SELECTOR: 'RewardsOndoRwaAssetSelector', REWARDS_ONDO_CAMPAIGN_PORTFOLIO_VIEW: 'RewardsOndoCampaignPortfolioView', REWARDS_ONDO_CAMPAIGN_STATS: 'RewardsOndoCampaignStats', diff --git a/app/core/Engine/controllers/rewards-controller/RewardsController-method-action-types.ts b/app/core/Engine/controllers/rewards-controller/RewardsController-method-action-types.ts index be1f9d95265..05948debe06 100644 --- a/app/core/Engine/controllers/rewards-controller/RewardsController-method-action-types.ts +++ b/app/core/Engine/controllers/rewards-controller/RewardsController-method-action-types.ts @@ -514,6 +514,19 @@ export type RewardsControllerGetOndoCampaignDepositsAction = { handler: RewardsController['getOndoCampaignDeposits']; }; +/** + * Fetch the participant outcome for the current user in a completed Perps Trading campaign. + * Results are cached for 10 minutes using a private in-memory Map. + * + * @param campaignId - The campaign ID. + * @param subscriptionId - The subscription ID for authentication. + * @returns The participant outcome DTO, or null if unavailable. + */ +export type RewardsControllerGetPerpsTradingCampaignParticipantOutcomeAction = { + type: `RewardsController:getPerpsTradingCampaignParticipantOutcome`; + handler: RewardsController['getPerpsTradingCampaignParticipantOutcome']; +}; + /** * Get the current user's position on the campaign leaderboard. * This is an authenticated endpoint. @@ -797,6 +810,7 @@ export type RewardsControllerMethodActions = | RewardsControllerGetCampaignParticipantStatusAction | RewardsControllerGetOndoCampaignLeaderboardAction | RewardsControllerGetOndoCampaignDepositsAction + | RewardsControllerGetPerpsTradingCampaignParticipantOutcomeAction | RewardsControllerGetOndoCampaignLeaderboardPositionAction | RewardsControllerGetOndoCampaignParticipantOutcomeAction | RewardsControllerGetOndoCampaignPortfolioPositionAction diff --git a/app/core/Engine/controllers/rewards-controller/RewardsController.test.ts b/app/core/Engine/controllers/rewards-controller/RewardsController.test.ts index ae1187bb7ad..f9b379bbe7a 100644 --- a/app/core/Engine/controllers/rewards-controller/RewardsController.test.ts +++ b/app/core/Engine/controllers/rewards-controller/RewardsController.test.ts @@ -20489,4 +20489,159 @@ describe('RewardsController', () => { ); }); }); + + describe('getPerpsTradingCampaignParticipantOutcome', () => { + let perpsParticipantOutcomeMessenger: jest.Mocked; + const mockCampaignId = 'perps-outcome-campaign-1'; + const mockSubscriptionId = 'sub-perps-outcome-1'; + const mockOutcome = { + subscriptionId: mockSubscriptionId, + outcomeStatus: 'pending' as const, + winnerVerificationCode: 'VERIFY-123', + rank: 1, + }; + + beforeEach(() => { + perpsParticipantOutcomeMessenger = { + subscribe: jest.fn(), + call: jest.fn(), + registerActionHandler: jest.fn(), + registerMethodActionHandlers: jest.fn(), + unregisterActionHandler: jest.fn(), + publish: jest.fn(), + clearEventSubscriptions: jest.fn(), + registerInitialEventPayload: jest.fn(), + unsubscribe: jest.fn(), + } as unknown as jest.Mocked; + }); + + it('returns null when rewards feature flag is disabled', async () => { + const disabledController = new RewardsController({ + messenger: perpsParticipantOutcomeMessenger, + state: getRewardsControllerDefaultState(), + isDisabled: () => true, + }); + + const result = + await disabledController.getPerpsTradingCampaignParticipantOutcome( + mockCampaignId, + mockSubscriptionId, + ); + + expect(result).toBeNull(); + expect(perpsParticipantOutcomeMessenger.call).not.toHaveBeenCalled(); + }); + + it('fetches outcome from API and caches result', async () => { + const ctrl = new RewardsController({ + messenger: perpsParticipantOutcomeMessenger, + state: getRewardsControllerDefaultState(), + }); + + perpsParticipantOutcomeMessenger.call.mockResolvedValue(mockOutcome); + + const result = await ctrl.getPerpsTradingCampaignParticipantOutcome( + mockCampaignId, + mockSubscriptionId, + ); + + expect(perpsParticipantOutcomeMessenger.call).toHaveBeenCalledWith( + 'RewardsDataService:getPerpsTradingCampaignParticipantOutcome', + mockCampaignId, + mockSubscriptionId, + ); + expect(result).toEqual(mockOutcome); + }); + + it('returns cached outcome on second call within TTL', async () => { + const ctrl = new RewardsController({ + messenger: perpsParticipantOutcomeMessenger, + state: getRewardsControllerDefaultState(), + }); + + perpsParticipantOutcomeMessenger.call.mockResolvedValue(mockOutcome); + + await ctrl.getPerpsTradingCampaignParticipantOutcome( + mockCampaignId, + mockSubscriptionId, + ); + + perpsParticipantOutcomeMessenger.call.mockClear(); + + const result = await ctrl.getPerpsTradingCampaignParticipantOutcome( + mockCampaignId, + mockSubscriptionId, + ); + + expect(result).toEqual(mockOutcome); + expect(perpsParticipantOutcomeMessenger.call).not.toHaveBeenCalled(); + }); + + it('returns null when API returns null and does not cache', async () => { + const ctrl = new RewardsController({ + messenger: perpsParticipantOutcomeMessenger, + state: getRewardsControllerDefaultState(), + }); + + perpsParticipantOutcomeMessenger.call.mockResolvedValue(null); + + const result = await ctrl.getPerpsTradingCampaignParticipantOutcome( + mockCampaignId, + mockSubscriptionId, + ); + + expect(result).toBeNull(); + + perpsParticipantOutcomeMessenger.call.mockClear(); + perpsParticipantOutcomeMessenger.call.mockResolvedValue(mockOutcome); + const second = await ctrl.getPerpsTradingCampaignParticipantOutcome( + mockCampaignId, + mockSubscriptionId, + ); + expect(perpsParticipantOutcomeMessenger.call).toHaveBeenCalledTimes(1); + expect(second).toEqual(mockOutcome); + }); + + it('returns null and logs on API error', async () => { + const ctrl = new RewardsController({ + messenger: perpsParticipantOutcomeMessenger, + state: getRewardsControllerDefaultState(), + }); + + perpsParticipantOutcomeMessenger.call.mockRejectedValue( + new Error('Perps API error'), + ); + mockLogger.error.mockClear(); + + const result = await ctrl.getPerpsTradingCampaignParticipantOutcome( + mockCampaignId, + mockSubscriptionId, + ); + + expect(result).toBeNull(); + expect(mockLogger.error).toHaveBeenCalledWith( + expect.any(Error), + 'RewardsController: Failed to fetch Perps Trading participant outcome', + ); + }); + + it('logs when fetching fresh outcome', async () => { + const ctrl = new RewardsController({ + messenger: perpsParticipantOutcomeMessenger, + state: getRewardsControllerDefaultState(), + }); + + perpsParticipantOutcomeMessenger.call.mockResolvedValue(mockOutcome); + mockLogger.log.mockClear(); + + await ctrl.getPerpsTradingCampaignParticipantOutcome( + mockCampaignId, + mockSubscriptionId, + ); + + expect(mockLogger.log).toHaveBeenCalledWith( + 'RewardsController: Fetching Perps Trading campaign participant outcome', + ); + }); + }); }); diff --git a/app/core/Engine/controllers/rewards-controller/RewardsController.ts b/app/core/Engine/controllers/rewards-controller/RewardsController.ts index 33aa24818e1..65056cd9adb 100644 --- a/app/core/Engine/controllers/rewards-controller/RewardsController.ts +++ b/app/core/Engine/controllers/rewards-controller/RewardsController.ts @@ -33,6 +33,7 @@ import { type PerpsTradingCampaignLeaderboardPositionDto, type PerpsTradingCampaignVolumeDto, type PaginatedOndoGmActivityDto, + type PerpsTradingCampaignParticipantOutcomeDto, type OndoGmActivityState, type PointsEstimateHistoryEntry, ClaimRewardDto, @@ -154,6 +155,9 @@ const PERPS_TRADING_CAMPAIGN_LEADERBOARD_POSITION_CACHE_THRESHOLD_MS = // Perps Trading Campaign volume cache threshold const PERPS_TRADING_CAMPAIGN_VOLUME_CACHE_THRESHOLD_MS = 1000 * 60 * 1; // 1 minute +// Perps Trading participant outcome cache threshold +const PERPS_TRADING_PARTICIPANT_OUTCOME_CACHE_THRESHOLD_MS = 1000 * 60 * 10; // 10 minutes + // Opt-in status stale threshold for not opted-in accounts to force a fresh check const NOT_OPTED_IN_OIS_STALE_CACHE_THRESHOLD_MS = 1000 * 60 * 60; // 1 hour @@ -447,6 +451,7 @@ const MESSENGER_EXPOSED_METHODS = [ 'getPerpsTradingCampaignLeaderboardPosition', 'getPerpsTradingCampaignVolume', 'getOptInStatus', + 'getPerpsTradingCampaignParticipantOutcome', 'getPerpsDiscountForAccount', 'getPointsEvents', 'getPointsEventsIfChanged', @@ -503,6 +508,13 @@ export class RewardsController extends BaseController< #isBitcoinOptinEnabled: () => boolean; #isTronOptinEnabled: () => boolean; #reauthPromises: Map> = new Map(); + #perpsTradingParticipantOutcomeCache: Map< + string, + { + payload: PerpsTradingCampaignParticipantOutcomeDto | null; + lastFetched: number; + } + > = new Map(); /** * Calculate tier status and next tier information @@ -3695,6 +3707,58 @@ export class RewardsController extends BaseController< return result; } + /** + * Fetch the participant outcome for the current user in a completed Perps Trading campaign. + * Results are cached for 10 minutes using a private in-memory Map. + * @param campaignId - The campaign ID. + * @param subscriptionId - The subscription ID for authentication. + * @returns The participant outcome DTO, or null if unavailable. + */ + async getPerpsTradingCampaignParticipantOutcome( + campaignId: string, + subscriptionId: string, + ): Promise { + if (!this.isRewardsFeatureEnabled()) { + return null; + } + const key = `${subscriptionId}:${campaignId}`; + try { + return await wrapWithCache( + { + key, + ttl: PERPS_TRADING_PARTICIPANT_OUTCOME_CACHE_THRESHOLD_MS, + readCache: (k) => + this.#perpsTradingParticipantOutcomeCache.get(k) ?? undefined, + fetchFresh: async () => + this.#withAuthRetry(async () => { + Logger.log( + 'RewardsController: Fetching Perps Trading campaign participant outcome', + ); + return this.messenger.call( + 'RewardsDataService:getPerpsTradingCampaignParticipantOutcome', + campaignId, + subscriptionId, + ); + }, subscriptionId), + writeCache: (k, payload) => { + if (payload !== null) { + this.#perpsTradingParticipantOutcomeCache.set(k, { + payload, + lastFetched: Date.now(), + }); + } + }, + }, + ); + } catch (error) { + Logger.error( + error as Error, + 'RewardsController: Failed to fetch Perps Trading participant outcome', + ); + return null; + } + } + /** * Get the current user's position on the campaign leaderboard. * This is an authenticated endpoint. diff --git a/app/core/Engine/controllers/rewards-controller/services/rewards-data-service.test.ts b/app/core/Engine/controllers/rewards-controller/services/rewards-data-service.test.ts index a7c8b574492..03f5e90af95 100644 --- a/app/core/Engine/controllers/rewards-controller/services/rewards-data-service.test.ts +++ b/app/core/Engine/controllers/rewards-controller/services/rewards-data-service.test.ts @@ -5088,6 +5088,59 @@ describe('RewardsDataService', () => { }); }); + describe('getPerpsTradingCampaignParticipantOutcome', () => { + const mockCampaignId = 'perps-outcome-campaign-1'; + const mockSubscriptionId = 'sub-perps-outcome-1'; + const mockToken = 'test-bearer-token'; + const mockOutcome = { + subscriptionId: mockSubscriptionId, + outcomeStatus: 'finalized' as const, + winnerVerificationCode: null, + rank: 3, + }; + + beforeEach(() => { + mockGetSubscriptionToken.mockResolvedValue({ + success: true, + token: mockToken, + }); + }); + + it('calls the authenticated perps outcome endpoint and returns data', async () => { + mockFetch.mockResolvedValue({ + ok: true, + json: jest.fn().mockResolvedValue(mockOutcome), + } as unknown as Response); + + const result = await service.getPerpsTradingCampaignParticipantOutcome( + mockCampaignId, + mockSubscriptionId, + ); + + expect(mockFetch).toHaveBeenCalledWith( + `https://uat.rewards.test/perps-trading/${mockCampaignId}/outcome/me`, + expect.objectContaining({ + method: 'GET', + headers: expect.objectContaining({ + 'rewards-access-token': mockToken, + }), + }), + ); + expect(result).toEqual(mockOutcome); + }); + + it('throws when response is not ok', async () => { + mockFetch.mockResolvedValue({ ok: false, status: 401 } as Response); + + await expect( + service.getPerpsTradingCampaignParticipantOutcome( + mockCampaignId, + mockSubscriptionId, + ), + ).rejects.toThrow('Get Perps Trading participant outcome failed: 401'); + }); + }); + describe('getPerpsTradingCampaignLeaderboard', () => { const mockCampaignId = 'perps-campaign-api-1'; const mockLeaderboard = { diff --git a/app/core/Engine/controllers/rewards-controller/services/rewards-data-service.ts b/app/core/Engine/controllers/rewards-controller/services/rewards-data-service.ts index 1170e963d60..28b22224532 100644 --- a/app/core/Engine/controllers/rewards-controller/services/rewards-data-service.ts +++ b/app/core/Engine/controllers/rewards-controller/services/rewards-data-service.ts @@ -38,6 +38,7 @@ import type { PerpsTradingCampaignLeaderboardDto, PerpsTradingCampaignLeaderboardPositionDto, PerpsTradingCampaignVolumeDto, + PerpsTradingCampaignParticipantOutcomeDto, } from '../types'; import { getSubscriptionToken } from '../utils/multi-subscription-token-vault'; import Logger from '../../../../../util/Logger'; @@ -280,6 +281,10 @@ export interface RewardsDataServiceGetPerpsTradingCampaignVolumeAction { type: `${typeof SERVICE_NAME}:getPerpsTradingCampaignVolume`; handler: RewardsDataService['getPerpsTradingCampaignVolume']; } +export interface RewardsDataServiceGetPerpsTradingCampaignParticipantOutcomeAction { + type: `${typeof SERVICE_NAME}:getPerpsTradingCampaignParticipantOutcome`; + handler: RewardsDataService['getPerpsTradingCampaignParticipantOutcome']; +} export interface RewardsDataServiceGetRewardsEnvUrlAction { type: `${typeof SERVICE_NAME}:getRewardsEnvUrl`; @@ -355,7 +360,8 @@ export type RewardsDataServiceActions = | RewardsDataServiceGetOndoCampaignParticipantOutcomeAction | RewardsDataServiceGetPerpsTradingCampaignLeaderboardAction | RewardsDataServiceGetPerpsTradingCampaignLeaderboardPositionAction - | RewardsDataServiceGetPerpsTradingCampaignVolumeAction; + | RewardsDataServiceGetPerpsTradingCampaignVolumeAction + | RewardsDataServiceGetPerpsTradingCampaignParticipantOutcomeAction; export type RewardsDataServiceMessenger = Messenger< typeof SERVICE_NAME, @@ -542,6 +548,10 @@ export class RewardsDataService { `${SERVICE_NAME}:getPerpsTradingCampaignVolume`, this.getPerpsTradingCampaignVolume.bind(this), ); + this.#messenger.registerActionHandler( + `${SERVICE_NAME}:getPerpsTradingCampaignParticipantOutcome`, + this.getPerpsTradingCampaignParticipantOutcome.bind(this), + ); this.#messenger.registerActionHandler( `${SERVICE_NAME}:getRewardsEnvUrl`, this.getRewardsEnvUrl.bind(this), @@ -1827,4 +1837,22 @@ export class RewardsDataService { return (await response.json()) as PerpsTradingCampaignVolumeDto; } + + async getPerpsTradingCampaignParticipantOutcome( + campaignId: string, + subscriptionId: string, + ): Promise { + const response = await this.makeRequest( + `/perps-trading/${campaignId}/outcome/me`, + { method: 'GET' }, + subscriptionId, + ); + if (!response.ok) { + throw new Error( + `Get Perps Trading participant outcome failed: ${response.status}`, + ); + } + + return (await response.json()) as PerpsTradingCampaignParticipantOutcomeDto; + } } diff --git a/app/core/Engine/controllers/rewards-controller/types.ts b/app/core/Engine/controllers/rewards-controller/types.ts index 16cd347e36f..be6feb66841 100644 --- a/app/core/Engine/controllers/rewards-controller/types.ts +++ b/app/core/Engine/controllers/rewards-controller/types.ts @@ -93,8 +93,8 @@ export interface ApplyBonusCodeDto { */ export enum CampaignType { ONDO_HOLDING = 'ONDO_HOLDING', - SEASON_1 = 'SEASON_1', PERPS_TRADING = 'PERPS_TRADING', + SEASON_1 = 'SEASON_1', } /** @@ -567,16 +567,33 @@ export type OndoGmCampaignDepositsDto = { totalUsdDeposited: string; }; -export type OndoGmCampaignParticipantOutcomeStatus = 'pending' | 'finalized'; +export type CampaignParticipantOutcomeStatus = 'pending' | 'finalized'; -export interface OndoGmCampaignParticipantOutcomeDto { +export interface BaseCampaignParticipantOutcomeDto { subscriptionId: string; - outcomeStatus: OndoGmCampaignParticipantOutcomeStatus; + outcomeStatus: CampaignParticipantOutcomeStatus; winnerVerificationCode?: string | null; +} + +/** @deprecated Use CampaignParticipantOutcomeStatus */ +export type OndoGmCampaignParticipantOutcomeStatus = + CampaignParticipantOutcomeStatus; + +export interface OndoGmCampaignParticipantOutcomeDto + extends BaseCampaignParticipantOutcomeDto { tierRank?: number; tier?: string; } +/** @deprecated Use CampaignParticipantOutcomeStatus */ +export type PerpsTradingCampaignParticipantOutcomeStatus = + CampaignParticipantOutcomeStatus; + +export interface PerpsTradingCampaignParticipantOutcomeDto + extends BaseCampaignParticipantOutcomeDto { + rank?: number | null; +} + /** * Cached portfolio payload (explicit shape for Json / StateConstraint compatibility). */ diff --git a/app/core/Engine/messengers/rewards-controller-messenger/index.ts b/app/core/Engine/messengers/rewards-controller-messenger/index.ts index 043c69b146c..d6e48449142 100644 --- a/app/core/Engine/messengers/rewards-controller-messenger/index.ts +++ b/app/core/Engine/messengers/rewards-controller-messenger/index.ts @@ -71,6 +71,7 @@ import { RewardsDataServiceGetPerpsTradingCampaignLeaderboardAction, RewardsDataServiceGetPerpsTradingCampaignLeaderboardPositionAction, RewardsDataServiceGetPerpsTradingCampaignVolumeAction, + RewardsDataServiceGetPerpsTradingCampaignParticipantOutcomeAction, } from '../../controllers/rewards-controller/services/rewards-data-service'; import { RootMessenger } from '../../types'; @@ -126,7 +127,8 @@ type AllowedActions = | RewardsDataServiceGetOndoCampaignParticipantOutcomeAction | RewardsDataServiceGetPerpsTradingCampaignLeaderboardAction | RewardsDataServiceGetPerpsTradingCampaignLeaderboardPositionAction - | RewardsDataServiceGetPerpsTradingCampaignVolumeAction; + | RewardsDataServiceGetPerpsTradingCampaignVolumeAction + | RewardsDataServiceGetPerpsTradingCampaignParticipantOutcomeAction; // Don't reexport as per guidelines type AllowedEvents = @@ -217,6 +219,7 @@ export function getRewardsControllerMessenger( 'RewardsDataService:getPerpsTradingCampaignLeaderboard', 'RewardsDataService:getPerpsTradingCampaignLeaderboardPosition', 'RewardsDataService:getPerpsTradingCampaignVolume', + 'RewardsDataService:getPerpsTradingCampaignParticipantOutcome', ], events: [ 'AccountTreeController:selectedAccountGroupChange', diff --git a/app/reducers/rewards/index.test.ts b/app/reducers/rewards/index.test.ts index 9ee33ec2753..59079e538a4 100644 --- a/app/reducers/rewards/index.test.ts +++ b/app/reducers/rewards/index.test.ts @@ -5857,35 +5857,33 @@ describe('ondoCampaignDeposits', () => { }); describe('dismissCampaignOutcomeToast', () => { - it('records winner_verify variant as dismissed', () => { + it('records winner variant as dismissed', () => { const state = rewardsReducer( initialState, dismissCampaignOutcomeToast({ - campaignId: 'campaign-1', - subscriptionId: 'sub-1', - variant: 'winner_verify', + campaignId: 'perps-c-1', + subscriptionId: 'sub-9', + variant: 'winner', }), ); expect( - state.dismissedCampaignOutcomeToasts['campaign-1:sub-1:winner_verify'], + state.dismissedCampaignOutcomeToasts['perps-c-1:sub-9:winner'], ).toBe(true); }); - it('records participant_no_winner variant as dismissed', () => { + it('records non_winner variant as dismissed', () => { const state = rewardsReducer( initialState, dismissCampaignOutcomeToast({ - campaignId: 'campaign-2', - subscriptionId: 'sub-2', - variant: 'participant_no_winner', + campaignId: 'ondo-c-1', + subscriptionId: 'sub-8', + variant: 'non_winner', }), ); expect( - state.dismissedCampaignOutcomeToasts[ - 'campaign-2:sub-2:participant_no_winner' - ], + state.dismissedCampaignOutcomeToasts['ondo-c-1:sub-8:non_winner'], ).toBe(true); }); @@ -5895,7 +5893,7 @@ describe('ondoCampaignDeposits', () => { dismissCampaignOutcomeToast({ campaignId: 'c1', subscriptionId: 's1', - variant: 'winner_verify', + variant: 'winner', }), ); state = rewardsReducer( @@ -5903,16 +5901,14 @@ describe('ondoCampaignDeposits', () => { dismissCampaignOutcomeToast({ campaignId: 'c2', subscriptionId: 's2', - variant: 'participant_no_winner', + variant: 'non_winner', }), ); - expect(state.dismissedCampaignOutcomeToasts['c1:s1:winner_verify']).toBe( + expect(state.dismissedCampaignOutcomeToasts['c1:s1:winner']).toBe(true); + expect(state.dismissedCampaignOutcomeToasts['c2:s2:non_winner']).toBe( true, ); - expect( - state.dismissedCampaignOutcomeToasts['c2:s2:participant_no_winner'], - ).toBe(true); }); it('starts with empty dismissedCampaignOutcomeToasts in initial state', () => { @@ -5925,7 +5921,7 @@ describe('ondoCampaignDeposits', () => { const persisted: RewardsState = { ...initialState, dismissedCampaignOutcomeToasts: { - 'campaign-1:sub-1:winner_verify': true, + 'campaign-1:sub-1:winner': true, }, }; @@ -5935,7 +5931,7 @@ describe('ondoCampaignDeposits', () => { }); expect(state.dismissedCampaignOutcomeToasts).toEqual({ - 'campaign-1:sub-1:winner_verify': true, + 'campaign-1:sub-1:winner': true, }); }); @@ -5958,7 +5954,7 @@ describe('ondoCampaignDeposits', () => { ...initialState, candidateSubscriptionId: 'old-sub', dismissedCampaignOutcomeToasts: { - 'campaign-1:old-sub:winner_verify': true, + 'campaign-1:old-sub:winner': true, }, }; @@ -5968,7 +5964,7 @@ describe('ondoCampaignDeposits', () => { ); expect(state.dismissedCampaignOutcomeToasts).toEqual({ - 'campaign-1:old-sub:winner_verify': true, + 'campaign-1:old-sub:winner': true, }); }); }); diff --git a/app/reducers/rewards/index.ts b/app/reducers/rewards/index.ts index b733c5c10d8..e35e437aaa3 100644 --- a/app/reducers/rewards/index.ts +++ b/app/reducers/rewards/index.ts @@ -866,7 +866,7 @@ const rewardsSlice = createSlice({ action: PayloadAction<{ campaignId: string; subscriptionId: string; - variant: 'winner_verify' | 'participant_no_winner'; + variant: 'winner' | 'non_winner'; }>, ) => { const key = `${action.payload.campaignId}:${action.payload.subscriptionId}:${action.payload.variant}`; diff --git a/app/reducers/rewards/selectors.test.ts b/app/reducers/rewards/selectors.test.ts index 9bac9897bab..9519b4cb778 100644 --- a/app/reducers/rewards/selectors.test.ts +++ b/app/reducers/rewards/selectors.test.ts @@ -51,6 +51,7 @@ import { selectCampaignsError, selectCampaignParticipantStatuses, selectCampaignParticipantStatus, + selectCampaignParticipantOptedIn, selectCampaignParticipantCount, selectIsRewardsVersionBlocked, selectVersionGuardMinimumMobileVersion, @@ -3285,6 +3286,39 @@ describe('Rewards selectors', () => { }); }); + describe('selectCampaignParticipantOptedIn', () => { + it('returns true when participant status is opted in', () => { + const state = createMockRootState({ + campaignParticipantStatuses: { + 'sub-1:campaign-1': { optedIn: true, participantCount: 42 }, + }, + }); + expect( + selectCampaignParticipantOptedIn('sub-1', 'campaign-1')(state), + ).toBe(true); + }); + + it('returns false when participant status is not opted in', () => { + const state = createMockRootState({ + campaignParticipantStatuses: { + 'sub-1:campaign-1': { optedIn: false, participantCount: 0 }, + }, + }); + expect( + selectCampaignParticipantOptedIn('sub-1', 'campaign-1')(state), + ).toBe(false); + }); + + it('returns false when status is missing', () => { + const state = createMockRootState({ + campaignParticipantStatuses: {}, + }); + expect( + selectCampaignParticipantOptedIn('sub-1', 'campaign-1')(state), + ).toBe(false); + }); + }); + describe('selectCampaignParticipantCount', () => { it('returns null when subscriptionId is undefined', () => { const state = createMockRootState({ @@ -3829,8 +3863,8 @@ describe('Rewards selectors', () => { it('returns the dismissed toasts map', () => { const dismissed = { - 'campaign-1:sub-1:winner_verify': true, - 'campaign-2:sub-1:participant_no_winner': true, + 'campaign-1:sub-1:winner': true, + 'campaign-2:sub-1:non_winner': true, }; const state = createMockRootState({ dismissedCampaignOutcomeToasts: dismissed, @@ -3841,11 +3875,11 @@ describe('Rewards selectors', () => { it('returns true for a dismissed toast key', () => { const state = createMockRootState({ dismissedCampaignOutcomeToasts: { - 'campaign-1:sub-1:winner_verify': true, + 'campaign-1:sub-1:winner': true, }, }); const result = selectDismissedCampaignOutcomeToasts(state); - expect(result['campaign-1:sub-1:winner_verify']).toBe(true); + expect(result['campaign-1:sub-1:winner']).toBe(true); }); it('returns undefined for a key that has not been dismissed', () => { @@ -3853,7 +3887,7 @@ describe('Rewards selectors', () => { dismissedCampaignOutcomeToasts: {}, }); const result = selectDismissedCampaignOutcomeToasts(state); - expect(result['campaign-1:sub-1:winner_verify']).toBeUndefined(); + expect(result['campaign-1:sub-1:winner']).toBeUndefined(); }); }); }); diff --git a/app/reducers/rewards/selectors.ts b/app/reducers/rewards/selectors.ts index 78fc6bc1d76..83c12f18cc9 100644 --- a/app/reducers/rewards/selectors.ts +++ b/app/reducers/rewards/selectors.ts @@ -188,6 +188,15 @@ export const selectCampaignParticipantStatus = return state.rewards.campaignParticipantStatuses?.[key] ?? null; }; +export const selectCampaignParticipantOptedIn = + ( + subscriptionId: string | undefined | null, + campaignId: string | undefined | null, + ) => + (state: RootState): boolean => + selectCampaignParticipantStatus(subscriptionId, campaignId)(state) + ?.optedIn === true; + export const selectCampaignParticipantCount = (subscriptionId: string | undefined, campaignId: string | undefined) => (state: RootState) => { diff --git a/app/selectors/rewards/index.ts b/app/selectors/rewards/index.ts index c4475cbb113..ca99456407f 100644 --- a/app/selectors/rewards/index.ts +++ b/app/selectors/rewards/index.ts @@ -43,17 +43,6 @@ export const selectRewardsSubscriptionId = createSelector( }, ); -export const selectCampaignParticipantOptedIn = - (subscriptionId: string | null, campaignId: string | undefined) => - (state: RootState): boolean => { - if (!subscriptionId || !campaignId) return false; - return ( - state.engine.backgroundState.RewardsController.campaignParticipantStatus[ - `${subscriptionId}:${campaignId}` - ]?.optedIn === true - ); - }; - export const selectRewardsActiveAccountAddress = createSelector( selectRewardsControllerState, (rewardsControllerState): string | null => { diff --git a/locales/languages/de.json b/locales/languages/de.json index 46be6a42eed..28b98585ba4 100644 --- a/locales/languages/de.json +++ b/locales/languages/de.json @@ -8658,29 +8658,18 @@ "retry_button": "Erneut versuchen", "refreshing": "Aktualisierung ..." }, - "ondo_campaign_winning": { - "you_won": "Sie haben gewonnen", - "rank_label": "{{place}} Ort", - "email_instructions": "Senden Sie eine E-Mail an ondocampaign@consensys.net mit Ihrem Code, um Ihren Preis zu erhalten.", - "open_mail": "E-Mail öffnen", - "skip_for_now": "Vorläufig überspringen", - "mail_subject": "Ondo-Kampagnenpreis einfordern", - "mail_body": "Mein Gewinncode: {{code}}", - "winning_code": "Gewinncode", - "close_a11y": "Schließen" - }, - "ondo_outcome_banner": { + "campaign_outcome_banner": { "winner_pending": { - "title": "Sie haben gewonnen", - "description": "Fordern Sie Ihren Preis noch heute ein.", - "a11y": "Gewinnerdetails öffnen" + "title": "Sie haben gewonnen!", + "description": "Überprüfen Sie Ihren Gewinncode und fordern Sie Ihren Preis noch heute ein.", + "a11y": "Details anzeigen" }, "participant_pending": { "title": "Kampagne ist beendet.", "description": "Wir ermitteln gerade die Ergebnisse. Schauen Sie bald wieder vorbei." }, "participant_finalized": { - "title": "Kampagnenergebnisse sind verfügbar", + "title": "Die Kampagnenergebnisse liegen vor.", "description": "Sie haben dieses Mal nicht gewonnen. Überprüfen Sie die Rangliste, um Ihre Platzierung einzusehen." }, "winner_finalized": { @@ -8688,17 +8677,28 @@ "description": "Ihre Belohnung ist unterwegs – wir werden uns in Kürze bei Ihnen melden." } }, - "ondo_outcome_toast": { - "winner_verify": { + "campaign_outcome_toast": { + "winner": { "title": "Sie haben gewonnen 💰", - "description": "Fordern Sie Ihren Preis aus der {{campaignName}} ein", + "description": "Fordern Sie Ihren Preis aus der {{campaignName}} noch heute ein.", "cta": "Details anzeigen" }, - "participant_no_winner": { - "title": "Die Ergebnisse sind verfügbar. 🏅", - "description": "Sehen Sie Ihre Platzierung in der {{campaignName}} ein", + "non_winner": { + "title": "Die Ergebnisse liegen vor 🏅", + "description": "Sehen Sie Ihre Platzierung in der {{campaignName}}.", "cta": "Ansehen" } + }, + "campaign_winning": { + "you_won": "Sie haben gewonnen", + "rank_label": "{{place}} Ort", + "email_instructions": "Senden Sie eine E-Mail an {{email}} mit Ihrem Code, um Ihren Preis zu erhalten.", + "open_mail": "E-Mail öffnen", + "skip_for_now": "Vorläufig überspringen", + "mail_subject": "{{campaignName}} – Preis einfordern", + "mail_body": "Mein Gewinncode: {{code}}", + "winning_code": "Gewinncode", + "close_a11y": "Schließen" } }, "time": { diff --git a/locales/languages/el.json b/locales/languages/el.json index 8df088c6d71..13ec23005cd 100644 --- a/locales/languages/el.json +++ b/locales/languages/el.json @@ -8658,29 +8658,18 @@ "retry_button": "Επανάληψη", "refreshing": "Ανανεώνεται..." }, - "ondo_campaign_winning": { - "you_won": "Κερδίσατε", - "rank_label": "θέση {{place}}", - "email_instructions": "Στείλτε email στο ondocampaign@consensys.net με τον κωδικό σας για να διεκδικήσετε το βραβείο σας.", - "open_mail": "Άνοιγμα αλληλογραφίας", - "skip_for_now": "Παράλειψη για τώρα", - "mail_subject": "Διεκδίκηση βραβείου στην καμπάνια του Ondo", - "mail_body": "Κωδικός νίκης: {{code}}", - "winning_code": "Κωδικός νίκης", - "close_a11y": "Κλείσιμο" - }, - "ondo_outcome_banner": { + "campaign_outcome_banner": { "winner_pending": { - "title": "Κερδίσατε", - "description": "Διεκδικήστε το βραβείο σας σήμερα.", - "a11y": "Άνοιγμα λεπτομερειών νικητή" + "title": "Κερδίσατε!", + "description": "Επαληθεύστε τον κωδικό νίκης σας και διεκδικήστε το βραβείο σας σήμερα.", + "a11y": "Προβολή λεπτομερειών" }, "participant_pending": { "title": "Η καμπάνια έχει λήξει.", "description": "Επεξεργαζόμαστε τα αποτελέσματα. Ελέγξτε ξανά σύντομα." }, "participant_finalized": { - "title": "Τα αποτελέσματα της καμπάνιας είναι έτοιμα", + "title": "Τα αποτελέσματα της καμπάνιας είναι έτοιμα.", "description": "Δεν κερδίσατε αυτή τη φορά. Δείτε τον πίνακα κατάταξης για να δείτε σε ποια θέση τερματίσατε." }, "winner_finalized": { @@ -8688,17 +8677,28 @@ "description": "Το βραβείο σας είναι καθ’ οδόν — θα επικοινωνήσουμε μαζί σας σύντομα." } }, - "ondo_outcome_toast": { - "winner_verify": { + "campaign_outcome_toast": { + "winner": { "title": "Κερδίσατε 💰", - "description": "Διεκδικήστε το βραβείο σας από την {{campaignName}}", + "description": "Διεκδικήστε το βραβείο σας από την {{campaignName}} σήμερα.", "cta": "Δείτε λεπτομέρειες" }, - "participant_no_winner": { - "title": "Τα αποτελέσματα είναι έτοιμα. 🏅", - "description": "Δείτε την κατάταξή σας στην {{campaignName}}", + "non_winner": { + "title": "Τα αποτελέσματα είναι έτοιμα 🏅", + "description": "Δείτε την κατάταξή σας στην {{campaignName}}.", "cta": "Προβολή" } + }, + "campaign_winning": { + "you_won": "Κερδίσατε", + "rank_label": "θέση {{place}}", + "email_instructions": "Στείλτε email στο {{email}} με τον κωδικό σας για να διεκδικήσετε το βραβείο σας.", + "open_mail": "Άνοιγμα αλληλογραφίας", + "skip_for_now": "Παράλειψη για τώρα", + "mail_subject": "{{campaignName}} — διεκδίκηση βραβείου", + "mail_body": "Κωδικός νίκης: {{code}}", + "winning_code": "Κωδικός νίκης", + "close_a11y": "Κλείσιμο" } }, "time": { diff --git a/locales/languages/en.json b/locales/languages/en.json index a222b35ae3b..7ef2b7ed478 100644 --- a/locales/languages/en.json +++ b/locales/languages/en.json @@ -8645,7 +8645,11 @@ "stats_qualify_for_rank_description": "Trade {{notionalRemaining}} more in notional volume to become eligible.", "stats_error_title": "Unable to load stats", "stats_error_description": "We had a problem loading your stats. Please try again.", - "stats_retry": "Retry" + "stats_retry": "Retry", + "completed_label_total_participants": "Total participants", + "completed_label_total_volume": "Total volume", + "completed_label_top_pnl": "Top PnL", + "completed_label_winners": "Winners" }, "campaigns_preview": { "title": "Campaigns", @@ -8671,29 +8675,29 @@ "retry_button": "Retry", "refreshing": "Refreshing..." }, - "ondo_campaign_winning": { + "campaign_winning": { "you_won": "You won", "rank_label": "{{place}} place", - "email_instructions": "Email ondocampaign@consensys.net with your code to claim your prize.", + "email_instructions": "Email {{email}} with your code to claim your prize.", "open_mail": "Open mail", "skip_for_now": "Skip for now", - "mail_subject": "Ondo campaign prize claim", + "mail_subject": "{{campaignName}} prize claim", "mail_body": "My winning code: {{code}}", "winning_code": "Winning code", "close_a11y": "Close" }, - "ondo_outcome_banner": { + "campaign_outcome_banner": { "winner_pending": { - "title": "You won", - "description": "Claim your prize today.", - "a11y": "Open winner details" + "title": "You won!", + "description": "Verify your winning code and claim your prize today.", + "a11y": "View details" }, "participant_pending": { "title": "Campaign has ended.", "description": "We're determining the results. Check back soon." }, "participant_finalized": { - "title": "Campaign results are in", + "title": "Campaign results are in.", "description": "You didn't win this time. Check the leaderboard to see where you finished." }, "winner_finalized": { @@ -8701,15 +8705,15 @@ "description": "Your reward is on its way — we'll be in touch shortly." } }, - "ondo_outcome_toast": { - "winner_verify": { + "campaign_outcome_toast": { + "winner": { "title": "You won 💰", - "description": "Claim your prize from the {{campaignName}}", + "description": "Claim your prize from the {{campaignName}} today.", "cta": "View details" }, - "participant_no_winner": { - "title": "The results are in. 🏅", - "description": "See your ranking in the {{campaignName}}", + "non_winner": { + "title": "The results are in 🏅", + "description": "See your ranking in the {{campaignName}}.", "cta": "View" } } diff --git a/locales/languages/es.json b/locales/languages/es.json index f228b7929ee..7a1d9ed8af8 100644 --- a/locales/languages/es.json +++ b/locales/languages/es.json @@ -8658,29 +8658,18 @@ "retry_button": "Reintentar", "refreshing": "Actualizando..." }, - "ondo_campaign_winning": { - "you_won": "Tú ganas", - "rank_label": "{{place}} lugar", - "email_instructions": "Para reclamar tu premio, envía un correo electrónico a ondocampaign@consensys.net con tu código.", - "open_mail": "Abrir correo", - "skip_for_now": "Omitir por ahora", - "mail_subject": "Reclamación de premio de la campaña de Ondo", - "mail_body": "Mi código ganador: {{code}}", - "winning_code": "Código ganador", - "close_a11y": "Cerrar" - }, - "ondo_outcome_banner": { + "campaign_outcome_banner": { "winner_pending": { - "title": "Tú ganas", - "description": "Reclama tu premio hoy mismo.", - "a11y": "Abrir detalles del ganador" + "title": "¡Has ganado!", + "description": "Verifica tu código ganador y reclama tu premio hoy.", + "a11y": "Ver detalles" }, "participant_pending": { "title": "La campaña terminó.", "description": "Estamos determinando los resultados. Vuelve a consultar pronto." }, "participant_finalized": { - "title": "Los resultados de la campaña están en", + "title": "Ya hay resultados de la campaña.", "description": "Esta vez no has ganado. Consulta la clasificación para ver en qué posición has quedado." }, "winner_finalized": { @@ -8688,17 +8677,28 @@ "description": "Tu recompensa está en camino; nos pondremos en contacto contigo en breve." } }, - "ondo_outcome_toast": { - "winner_verify": { + "campaign_outcome_toast": { + "winner": { "title": "Tú ganas 💰", - "description": "Reclama tu premio en la {{campaignName}}", + "description": "Reclama tu premio de la {{campaignName}} hoy.", "cta": "Ver detalles" }, - "participant_no_winner": { - "title": "Ya tenemos los resultados. 🏅", - "description": "Consulta tu clasificación en {{campaignName}}", + "non_winner": { + "title": "Ya hay resultados 🏅", + "description": "Consulta tu posición en la {{campaignName}}.", "cta": "Ver" } + }, + "campaign_winning": { + "you_won": "Tú ganas", + "rank_label": "{{place}} lugar", + "email_instructions": "Para reclamar tu premio, envía un correo electrónico a {{email}} con tu código.", + "open_mail": "Abrir correo", + "skip_for_now": "Omitir por ahora", + "mail_subject": "{{campaignName}} – reclamación de premio", + "mail_body": "Mi código ganador: {{code}}", + "winning_code": "Código ganador", + "close_a11y": "Cerrar" } }, "time": { diff --git a/locales/languages/fr.json b/locales/languages/fr.json index 5d699bb6f69..22621832f2d 100644 --- a/locales/languages/fr.json +++ b/locales/languages/fr.json @@ -8658,29 +8658,18 @@ "retry_button": "Réessayer", "refreshing": "Actualisation en cours…" }, - "ondo_campaign_winning": { - "you_won": "Vous avez gagné", - "rank_label": "{{place}} place", - "email_instructions": "Envoyez votre code par e-mail à ondocampaign@consensys.net pour réclamer votre récompense.", - "open_mail": "Ouvrir l’e-mail", - "skip_for_now": "Ignorer pour l’instant", - "mail_subject": "Réclamation du prix que vous avez gagné en participant à la campagne Ondo", - "mail_body": "Mon code gagnant : {{code}}", - "winning_code": "Code gagnant", - "close_a11y": "Fermer" - }, - "ondo_outcome_banner": { + "campaign_outcome_banner": { "winner_pending": { - "title": "Vous avez gagné", - "description": "Réclamez votre prix dès aujourd’hui.", - "a11y": "Afficher les détails du gagnant" + "title": "Vous avez gagné !", + "description": "Vérifiez votre code gagnant et réclamez votre récompense aujourd’hui.", + "a11y": "Voir les détails" }, "participant_pending": { "title": "La campagne est terminée.", "description": "Nous sommes en train d’évaluer les résultats. Revenez bientôt." }, "participant_finalized": { - "title": "Les résultats de la campagne sont disponibles", + "title": "Les résultats de la campagne sont disponibles.", "description": "Vous n’avez pas gagné cette fois-ci. Découvrez quelle place vous occupez au classement final." }, "winner_finalized": { @@ -8688,17 +8677,28 @@ "description": "Votre prix est en route. Nous vous contacterons bientôt." } }, - "ondo_outcome_toast": { - "winner_verify": { + "campaign_outcome_toast": { + "winner": { "title": "Vous avez gagné 💰", - "description": "Réclamez le prix que vous avez gagné en participant à la campagne {{campaignName}}", + "description": "Réclamez votre récompense de la {{campaignName}} aujourd’hui.", "cta": "Voir les détails" }, - "participant_no_winner": { - "title": "Les résultats sont disponibles. 🏅", - "description": "Découvrez votre classement dans la campagne {{campaignName}}", + "non_winner": { + "title": "Les résultats sont disponibles 🏅", + "description": "Consultez votre classement dans la {{campaignName}}.", "cta": "Afficher" } + }, + "campaign_winning": { + "you_won": "Vous avez gagné", + "rank_label": "{{place}} place", + "email_instructions": "Envoyez votre code par e-mail à {{email}} pour réclamer votre récompense.", + "open_mail": "Ouvrir l’e-mail", + "skip_for_now": "Ignorer pour l’instant", + "mail_subject": "{{campaignName}} – réclamation de prix", + "mail_body": "Mon code gagnant : {{code}}", + "winning_code": "Code gagnant", + "close_a11y": "Fermer" } }, "time": { diff --git a/locales/languages/hi.json b/locales/languages/hi.json index 962fd14ccc8..ced9d00e771 100644 --- a/locales/languages/hi.json +++ b/locales/languages/hi.json @@ -8658,29 +8658,18 @@ "retry_button": "फिर से प्रयास करें", "refreshing": "रिफ्रेश हो रहा है..." }, - "ondo_campaign_winning": { - "you_won": "आप जीत गए", - "rank_label": "{{place}} जगह", - "email_instructions": "अपना इनाम पाने के लिए ondocampaign@consensys.net पर अपना कोड ईमेल करें।", - "open_mail": "मेल खोलें", - "skip_for_now": "अभी के लिए स्किप करें", - "mail_subject": "Ondo कैंपेन प्राइज़ क्लेम", - "mail_body": "मेरा विनिंग कोड: {{code}}", - "winning_code": "विनिंग कोड", - "close_a11y": "बंद करें" - }, - "ondo_outcome_banner": { + "campaign_outcome_banner": { "winner_pending": { - "title": "आप जीत गए", - "description": "आज ही अपना प्राइज़ क्लेम करें।", - "a11y": "विनर की जानकारी खोलें" + "title": "आप जीत गए!", + "description": "अपना विजेता कोड सत्यापित करें और आज ही अपना इनाम दावा करें।", + "a11y": "विवरण देखें" }, "participant_pending": { "title": "कैंपेन खत्म हो गया है।", "description": "हम रिज़ल्ट तय कर रहे हैं। जल्द ही वापस आकर देखें।" }, "participant_finalized": { - "title": "कैंपेन के रिज़ल्ट आ गए हैं", + "title": "कैंपेन के परिणाम आ गए हैं।", "description": "आप इस बार नहीं जीते। लीडरबोर्ड देखें कि आप कहाँ पर हैं।" }, "winner_finalized": { @@ -8688,17 +8677,28 @@ "description": "आपका रिवॉर्ड आने वाला है — हम जल्द ही आपसे कॉन्टैक्ट करेंगे।" } }, - "ondo_outcome_toast": { - "winner_verify": { + "campaign_outcome_toast": { + "winner": { "title": "आप जीत गए 💰", - "description": "{{campaignName}} से अपना प्राइज़ क्लेम करें", + "description": "आज ही {{campaignName}} से अपना इनाम दावा करें।", "cta": "विवरण देखें" }, - "participant_no_winner": { - "title": "रिज़ल्ट आ गए हैं। 🏅", - "description": "{{campaignName}} में अपनी रैंकिंग देखें", + "non_winner": { + "title": "परिणाम आ गए हैं 🏅", + "description": "{{campaignName}} में अपनी रैंकिंग देखें।", "cta": "देखें" } + }, + "campaign_winning": { + "you_won": "आप जीत गए", + "rank_label": "{{place}} जगह", + "email_instructions": "अपना इनाम पाने के लिए {{email}} पर अपना कोड ईमेल करें।", + "open_mail": "मेल खोलें", + "skip_for_now": "अभी के लिए स्किप करें", + "mail_subject": "{{campaignName}} – पुरस्कार दावा", + "mail_body": "मेरा विनिंग कोड: {{code}}", + "winning_code": "विनिंग कोड", + "close_a11y": "बंद करें" } }, "time": { diff --git a/locales/languages/id.json b/locales/languages/id.json index 487c8399e66..b9384ae959a 100644 --- a/locales/languages/id.json +++ b/locales/languages/id.json @@ -8658,29 +8658,18 @@ "retry_button": "Coba lagi", "refreshing": "Menyegarkan..." }, - "ondo_campaign_winning": { - "you_won": "Anda menang", - "rank_label": "{{place}} tempat", - "email_instructions": "Kirimkan kode Anda ke ondocampaign@consensys.net untuk mengklaim hadiah Anda.", - "open_mail": "Buka surat", - "skip_for_now": "Lewati untuk saat ini", - "mail_subject": "Klaim hadiah kampanye Ondo", - "mail_body": "Kode pemenang: {{code}}", - "winning_code": "Kode pemenang", - "close_a11y": "Tutup" - }, - "ondo_outcome_banner": { + "campaign_outcome_banner": { "winner_pending": { - "title": "Anda menang", - "description": "Klaim hadiah Anda hari ini.", - "a11y": "Buka detail pemenang" + "title": "Anda menang!", + "description": "Verifikasi kode kemenangan Anda dan klaim hadiah Anda hari ini.", + "a11y": "Lihat detail" }, "participant_pending": { "title": "Kampanye telah berakhir.", "description": "Kami sedang menentukan hasilnya. Periksa kembali sesaat lagi." }, "participant_finalized": { - "title": "Hasil kampanye telah keluar", + "title": "Hasil kampanye sudah tersedia.", "description": "Anda tidak menang kali ini. Periksa papan peringkat untuk melihat posisi Anda." }, "winner_finalized": { @@ -8688,17 +8677,28 @@ "description": "Reward sedang dikirim — kami akan segera menghubungi Anda." } }, - "ondo_outcome_toast": { - "winner_verify": { + "campaign_outcome_toast": { + "winner": { "title": "Anda menang 💰", - "description": "Klaim hadiah dari {{campaignName}}", + "description": "Klaim hadiah Anda dari {{campaignName}} hari ini.", "cta": "Lihat detail" }, - "participant_no_winner": { - "title": "Hasilnya sudah keluar. 🏅", - "description": "Lihat peringkat Anda di {{campaignName}}", + "non_winner": { + "title": "Hasil sudah tersedia 🏅", + "description": "Lihat peringkat Anda di {{campaignName}}.", "cta": "Lihat" } + }, + "campaign_winning": { + "you_won": "Anda menang", + "rank_label": "{{place}} tempat", + "email_instructions": "Kirimkan kode Anda ke {{email}} untuk mengklaim hadiah Anda.", + "open_mail": "Buka surat", + "skip_for_now": "Lewati untuk saat ini", + "mail_subject": "Klaim hadiah {{campaignName}}", + "mail_body": "Kode pemenang: {{code}}", + "winning_code": "Kode pemenang", + "close_a11y": "Tutup" } }, "time": { diff --git a/locales/languages/ja.json b/locales/languages/ja.json index 9950f780af3..62c3a10836a 100644 --- a/locales/languages/ja.json +++ b/locales/languages/ja.json @@ -8658,29 +8658,18 @@ "retry_button": "再試行", "refreshing": "更新中..." }, - "ondo_campaign_winning": { - "you_won": "予測的中", - "rank_label": "{{place}}位", - "email_instructions": "賞品を請求するには、ondocampaign@consensys.netにメールでコードを送信してください。", - "open_mail": "メールを開く", - "skip_for_now": "今はスキップ", - "mail_subject": "Ondoキャンペーン賞品の請求", - "mail_body": "勝利コード: {{code}}", - "winning_code": "勝利コード", - "close_a11y": "閉じる" - }, - "ondo_outcome_banner": { + "campaign_outcome_banner": { "winner_pending": { - "title": "予測的中", - "description": "今すぐ賞品を請求しましょう。", - "a11y": "勝者の詳細を開く" + "title": "当選しました!", + "description": "当選コードを確認し、本日中に賞品を請求してください。", + "a11y": "詳細を表示" }, "participant_pending": { "title": "キャンペーンが終了しました。", "description": "結果を判断しています。近々またご確認ください。" }, "participant_finalized": { - "title": "キャンペーンの結果が出ました", + "title": "キャンペーンの結果が出ました。", "description": "今回は勝ちませんでした。リーダーボードで順位を確認しましょう。" }, "winner_finalized": { @@ -8688,17 +8677,28 @@ "description": "間もなく報酬を受け取れます。近々ご連絡します。" } }, - "ondo_outcome_toast": { - "winner_verify": { + "campaign_outcome_toast": { + "winner": { "title": "予測的中 💰", - "description": "{{campaignName}}の賞品を請求", + "description": "本日、{{campaignName}}の賞品を請求してください。", "cta": "詳細を表示" }, - "participant_no_winner": { - "title": "結果が出ました。🏅", - "description": "{{campaignName}}でのランキングをご覧ください", + "non_winner": { + "title": "結果が出ました 🏅", + "description": "{{campaignName}}での順位を確認してください。", "cta": "表示" } + }, + "campaign_winning": { + "you_won": "予測的中", + "rank_label": "{{place}}位", + "email_instructions": "賞品を請求するには、{{email}}にメールでコードを送信してください。", + "open_mail": "メールを開く", + "skip_for_now": "今はスキップ", + "mail_subject": "{{campaignName}} 賞品の請求", + "mail_body": "勝利コード: {{code}}", + "winning_code": "勝利コード", + "close_a11y": "閉じる" } }, "time": { diff --git a/locales/languages/ko.json b/locales/languages/ko.json index 7406260871f..01372f65546 100644 --- a/locales/languages/ko.json +++ b/locales/languages/ko.json @@ -8658,29 +8658,18 @@ "retry_button": "다시 시도", "refreshing": "새로 고침 중..." }, - "ondo_campaign_winning": { - "you_won": "승리하셨습니다", - "rank_label": "{{place}} 장소", - "email_instructions": "상품을 받으려면 당첨 코드를 ondocampaign@consensys.net으로 이메일로 보내세요.", - "open_mail": "메일 열기", - "skip_for_now": "지금은 건너뛰기", - "mail_subject": "Ondo 캠페인 상품 청구", - "mail_body": "당첨 코드: {{code}}", - "winning_code": "당첨 코드", - "close_a11y": "닫기" - }, - "ondo_outcome_banner": { + "campaign_outcome_banner": { "winner_pending": { - "title": "승리하셨습니다", - "description": "오늘 상품을 청구하세요.", - "a11y": "당첨자 세부 정보 열기" + "title": "당첨되셨습니다!", + "description": "당첨 코드를 확인하고 오늘 상품을 청구하세요.", + "a11y": "세부정보 보기" }, "participant_pending": { "title": "캠페인이 종료되었습니다.", "description": "결과를 확인 중입니다. 곧 다시 확인해 주세요." }, "participant_finalized": { - "title": "캠페인 결과가 발표되었습니다", + "title": "캠페인 결과가 나왔습니다.", "description": "이번에는 당첨되지 않았습니다. 리더보드에서 순위를 확인해 보세요." }, "winner_finalized": { @@ -8688,17 +8677,28 @@ "description": "보상이 지급될 예정입니다. 곧 연락드리겠습니다." } }, - "ondo_outcome_toast": { - "winner_verify": { + "campaign_outcome_toast": { + "winner": { "title": "승리하셨습니다 💰", - "description": "{{campaignName}} 상품을 청구하세요", + "description": "오늘 {{campaignName}}에서 상품을 청구하세요.", "cta": "세부 정보 보기" }, - "participant_no_winner": { - "title": "결과가 발표되었습니다. 🏅", - "description": "{{campaignName}}에서 내 순위 보기", + "non_winner": { + "title": "결과가 나왔습니다 🏅", + "description": "{{campaignName}}에서 내 순위를 확인하세요.", "cta": "보기" } + }, + "campaign_winning": { + "you_won": "승리하셨습니다", + "rank_label": "{{place}} 장소", + "email_instructions": "상품을 받으려면 당첨 코드를 {{email}}으로 이메일로 보내세요.", + "open_mail": "메일 열기", + "skip_for_now": "지금은 건너뛰기", + "mail_subject": "{{campaignName}} 상품 청구", + "mail_body": "당첨 코드: {{code}}", + "winning_code": "당첨 코드", + "close_a11y": "닫기" } }, "time": { diff --git a/locales/languages/pt.json b/locales/languages/pt.json index bc8a465f6fa..9e606f5776b 100644 --- a/locales/languages/pt.json +++ b/locales/languages/pt.json @@ -8658,29 +8658,18 @@ "retry_button": "Tentar novamente", "refreshing": "Atualizando..." }, - "ondo_campaign_winning": { - "you_won": "Você ganhou", - "rank_label": "{{place}} lugar", - "email_instructions": "Envie um e-mail para ondocampaign@consensys.net com o seu código para reivindicar seu prêmio.", - "open_mail": "Abrir e-mail", - "skip_for_now": "Ignorar por enquanto", - "mail_subject": "Reivindicação de prêmio da campanha Ondo", - "mail_body": "Meu código ganhador: {{code}}", - "winning_code": "Código ganhador", - "close_a11y": "Fechar" - }, - "ondo_outcome_banner": { + "campaign_outcome_banner": { "winner_pending": { - "title": "Você ganhou", - "description": "Reivindique seu prêmio hoje mesmo.", - "a11y": "Ver detalhes dos ganhadores" + "title": "Você ganhou!", + "description": "Verifique seu código vencedor e reivindique seu prêmio hoje.", + "a11y": "Ver detalhes" }, "participant_pending": { "title": "A campanha terminou.", "description": "Estamos analisando os resultados. Volte em breve." }, "participant_finalized": { - "title": "Os resultados da campanha foram divulgados", + "title": "Os resultados da campanha estão disponíveis.", "description": "Você não ganhou desta vez. Confira o placar de classificação para ver sua posição final." }, "winner_finalized": { @@ -8688,17 +8677,28 @@ "description": "Sua recompensa está a caminho. Entraremos em contato em breve." } }, - "ondo_outcome_toast": { - "winner_verify": { + "campaign_outcome_toast": { + "winner": { "title": "Você ganhou 💰", - "description": "Reivindique seu prêmio da {{campaignName}}", + "description": "Reivindique seu prêmio da {{campaignName}} hoje.", "cta": "Ver detalhes" }, - "participant_no_winner": { - "title": "Os resultados foram divulgados.🏅", - "description": "Veja sua classificação na {{campaignName}}", + "non_winner": { + "title": "Os resultados estão disponíveis 🏅", + "description": "Veja sua classificação na {{campaignName}}.", "cta": "Ver" } + }, + "campaign_winning": { + "you_won": "Você ganhou", + "rank_label": "{{place}} lugar", + "email_instructions": "Envie um e-mail para {{email}} com o seu código para reivindicar seu prêmio.", + "open_mail": "Abrir e-mail", + "skip_for_now": "Ignorar por enquanto", + "mail_subject": "{{campaignName}} – reivindicação de prêmio", + "mail_body": "Meu código ganhador: {{code}}", + "winning_code": "Código ganhador", + "close_a11y": "Fechar" } }, "time": { diff --git a/locales/languages/ru.json b/locales/languages/ru.json index ef6b6f31ae2..f292c81431e 100644 --- a/locales/languages/ru.json +++ b/locales/languages/ru.json @@ -8658,29 +8658,18 @@ "retry_button": "Повтор", "refreshing": "Обновление..." }, - "ondo_campaign_winning": { - "you_won": "Вы выиграли", - "rank_label": "{{place}} место", - "email_instructions": "Отправьте письмо со своим кодом на адрес ondocampaign@consensys.net, чтобы получить приз.", - "open_mail": "Открыть почту", - "skip_for_now": "Пока пропустить", - "mail_subject": "Получение приза кампании Ondo", - "mail_body": "Мой выигрышный код: {{code}}", - "winning_code": "Выигрышный код", - "close_a11y": "Закрыть" - }, - "ondo_outcome_banner": { + "campaign_outcome_banner": { "winner_pending": { - "title": "Вы выиграли", - "description": "Получите свой приз сегодня.", - "a11y": "Открыть детали победителя" + "title": "Вы выиграли!", + "description": "Подтвердите выигрышный код и получите приз сегодня.", + "a11y": "Подробнее" }, "participant_pending": { "title": "Кампания завершена.", "description": "Мы определяем результаты. Загляните сюда позже." }, "participant_finalized": { - "title": "Результаты кампании подведены", + "title": "Результаты кампании готовы.", "description": "В этот раз вы не выиграли. Проверьте таблицу лидеров, чтобы узнать свое место." }, "winner_finalized": { @@ -8688,17 +8677,28 @@ "description": "Ваша награда уже в пути — мы скоро свяжемся с вами." } }, - "ondo_outcome_toast": { - "winner_verify": { + "campaign_outcome_toast": { + "winner": { "title": "Вы выиграли 💰", - "description": "Получите свой приз в кампании {{campaignName}}", + "description": "Получите приз из {{campaignName}} сегодня.", "cta": "Просмотр подробностей" }, - "participant_no_winner": { - "title": "Результаты готовы. 🏅", - "description": "Посмотрите свой рейтинг в кампании {{campaignName}}", + "non_winner": { + "title": "Результаты готовы 🏅", + "description": "Посмотрите своё место в {{campaignName}}.", "cta": "Просмотр" } + }, + "campaign_winning": { + "you_won": "Вы выиграли", + "rank_label": "{{place}} место", + "email_instructions": "Отправьте письмо со своим кодом на адрес {{email}}, чтобы получить приз.", + "open_mail": "Открыть почту", + "skip_for_now": "Пока пропустить", + "mail_subject": "{{campaignName}} – получение приза", + "mail_body": "Мой выигрышный код: {{code}}", + "winning_code": "Выигрышный код", + "close_a11y": "Закрыть" } }, "time": { diff --git a/locales/languages/tl.json b/locales/languages/tl.json index a1cc9eb084e..c5b4425bcbd 100644 --- a/locales/languages/tl.json +++ b/locales/languages/tl.json @@ -8658,29 +8658,18 @@ "retry_button": "Subukang muli", "refreshing": "Nire-refresh..." }, - "ondo_campaign_winning": { - "you_won": "Nanalo ka", - "rank_label": "{{place}} lugar", - "email_instructions": "I-email sa ondocampaign@consensys.net ang code mo para kunin ang premyo mo.", - "open_mail": "Buksan ang mail", - "skip_for_now": "Laktawan muna", - "mail_subject": "Pagkuha ng premyo ng campaign ng Ondo", - "mail_body": "Ang nanalong code: {{code}}", - "winning_code": "Nanalong code", - "close_a11y": "Isara" - }, - "ondo_outcome_banner": { + "campaign_outcome_banner": { "winner_pending": { - "title": "Nanalo ka", - "description": "Kunin ang premyo mo ngayon.", - "a11y": "Ipakita ang mga detalye ng nanalo" + "title": "Nanalo ka!", + "description": "I-verify ang winning code mo at kunin ang premyo mo ngayon.", + "a11y": "Tingnan ang mga detalye" }, "participant_pending": { "title": "Tapos na ang campaign.", "description": "Inaalam na namin ang resulta. Bumalik sa lalong madaling panahon." }, "participant_finalized": { - "title": "Lumabas na ang resulta ng campaign", + "title": "Lumabas na ang resulta ng campaign.", "description": "Hindi ka nanalo sa pagkakataong ito. Tingnan ang leaderboard para malaman kung ano ang rank mo." }, "winner_finalized": { @@ -8688,17 +8677,28 @@ "description": "Parating na ang reward mo — makikipag-ugnayan kami sa iyo sa lalong madaling panahon." } }, - "ondo_outcome_toast": { - "winner_verify": { + "campaign_outcome_toast": { + "winner": { "title": "Nanalo ka 💰", - "description": "Kunin ang premyo mo mula sa {{campaignName}}", + "description": "Kunin ang premyo mo mula sa {{campaignName}} ngayon.", "cta": "Tingnan ang mga detalye" }, - "participant_no_winner": { - "title": "Lumabas na ang resulta. 🏅", - "description": "Tingnan ang ranking mo sa {{campaignName}}", + "non_winner": { + "title": "Lumabas na ang resulta 🏅", + "description": "Tingnan ang ranking mo sa {{campaignName}}.", "cta": "Tingnan" } + }, + "campaign_winning": { + "you_won": "Nanalo ka", + "rank_label": "{{place}} lugar", + "email_instructions": "I-email sa {{email}} ang code mo para kunin ang premyo mo.", + "open_mail": "Buksan ang mail", + "skip_for_now": "Laktawan muna", + "mail_subject": "{{campaignName}} – pagkuha ng premyo", + "mail_body": "Ang nanalong code: {{code}}", + "winning_code": "Nanalong code", + "close_a11y": "Isara" } }, "time": { diff --git a/locales/languages/tr.json b/locales/languages/tr.json index 3fc8b5216ac..2a71fe184a4 100644 --- a/locales/languages/tr.json +++ b/locales/languages/tr.json @@ -8658,29 +8658,18 @@ "retry_button": "Tekrar Dene", "refreshing": "Yenileniyor..." }, - "ondo_campaign_winning": { - "you_won": "Kazancınız", - "rank_label": "{{place}}. sıra", - "email_instructions": "Ödülünüzü almak için kodunuzla ondocampaign@consensys.net adresine e-posta gönderin.", - "open_mail": "Postayı aç", - "skip_for_now": "Şimdilik atla", - "mail_subject": "Ondo kampanyası ödül talebi", - "mail_body": "Kazanma kodum: {{code}}", - "winning_code": "Kazanma kodu", - "close_a11y": "Kapat" - }, - "ondo_outcome_banner": { + "campaign_outcome_banner": { "winner_pending": { - "title": "Kazancınız", - "description": "Ödülünüzü bugün alın.", - "a11y": "Kazanan bilgilerini aç" + "title": "Kazandınız!", + "description": "Kazanan kodunuzu doğrulayın ve ödülünüzü bugün talep edin.", + "a11y": "Ayrıntıları görüntüle" }, "participant_pending": { "title": "Kampanya sona erdi.", "description": "Sonuçları belirliyoruz. Kısa bir süre tekrar kontrol edin." }, "participant_finalized": { - "title": "Kampanya sonuçları açıklandı", + "title": "Kampanya sonuçları hazır.", "description": "Bu kez kazanamadınız. Nerede bitirdiğinizi görmek için liderlik tablosunu kontrol edin." }, "winner_finalized": { @@ -8688,17 +8677,28 @@ "description": "Ödülünüz yolda - kısa bir süre sonra iletişime geçeceğiz." } }, - "ondo_outcome_toast": { - "winner_verify": { + "campaign_outcome_toast": { + "winner": { "title": "Kazancınız 💰", - "description": "{{campaignName}} adlı kampanyadan ödülünüzü alın", + "description": "Ödülünüzü bugün {{campaignName}} üzerinden talep edin.", "cta": "Ayrıntıları görüntüle" }, - "participant_no_winner": { - "title": "Sonuçlar belli oldu. 🏅", - "description": "{{campaignName}} adlı kampanyadaki sıralamanızı görün", + "non_winner": { + "title": "Sonuçlar hazır 🏅", + "description": "{{campaignName}} içinde sıralamanızı görün.", "cta": "Görüntüle" } + }, + "campaign_winning": { + "you_won": "Kazancınız", + "rank_label": "{{place}}. sıra", + "email_instructions": "Ödülünüzü almak için kodunuzla {{email}} adresine e-posta gönderin.", + "open_mail": "Postayı aç", + "skip_for_now": "Şimdilik atla", + "mail_subject": "{{campaignName}} – ödül talebi", + "mail_body": "Kazanma kodum: {{code}}", + "winning_code": "Kazanma kodu", + "close_a11y": "Kapat" } }, "time": { diff --git a/locales/languages/vi.json b/locales/languages/vi.json index a3e14e0a16b..6fc67e4c02d 100644 --- a/locales/languages/vi.json +++ b/locales/languages/vi.json @@ -8658,29 +8658,18 @@ "retry_button": "Thử lại", "refreshing": "Đang làm mới..." }, - "ondo_campaign_winning": { - "you_won": "Bạn đã thắng", - "rank_label": "Hạng {{place}}", - "email_instructions": "Gửi email đến ondocampaign@consensys.net kèm mã của bạn để nhận thưởng.", - "open_mail": "Mở thư", - "skip_for_now": "Bỏ qua bây giờ", - "mail_subject": "Nhận thưởng chiến dịch Ondo", - "mail_body": "Mã trúng thưởng của tôi: {{code}}", - "winning_code": "Mã trúng thưởng", - "close_a11y": "Đóng" - }, - "ondo_outcome_banner": { + "campaign_outcome_banner": { "winner_pending": { - "title": "Bạn đã thắng", - "description": "Nhận giải thưởng của bạn ngay hôm nay.", - "a11y": "Mở thông tin người chiến thắng" + "title": "Bạn đã thắng!", + "description": "Xác minh mã trúng thưởng và nhận giải thưởng hôm nay.", + "a11y": "Xem chi tiết" }, "participant_pending": { "title": "Chiến dịch đã kết thúc.", "description": "Chúng tôi đang xác định kết quả. Hãy quay lại sau." }, "participant_finalized": { - "title": "Kết quả chiến dịch đã có", + "title": "Đã có kết quả chiến dịch.", "description": "Lần này bạn đã không giành chiến thắng. Hãy kiểm tra bảng xếp hạng để xem bạn đứng ở vị trí nào." }, "winner_finalized": { @@ -8688,17 +8677,28 @@ "description": "Phần thưởng của bạn đang được gửi — chúng tôi sẽ sớm liên hệ với bạn." } }, - "ondo_outcome_toast": { - "winner_verify": { + "campaign_outcome_toast": { + "winner": { "title": "Bạn đã thắng 💰", - "description": "Nhận giải thưởng của bạn từ {{campaignName}}", + "description": "Nhận thưởng từ {{campaignName}} hôm nay.", "cta": "Xem chi tiết" }, - "participant_no_winner": { - "title": "Kết quả đã có. 🏅", - "description": "Xem thứ hạng của bạn trong {{campaignName}}", + "non_winner": { + "title": "Đã có kết quả 🏅", + "description": "Xem thứ hạng của bạn trong {{campaignName}}.", "cta": "Xem" } + }, + "campaign_winning": { + "you_won": "Bạn đã thắng", + "rank_label": "Hạng {{place}}", + "email_instructions": "Gửi email đến {{email}} kèm mã của bạn để nhận thưởng.", + "open_mail": "Mở thư", + "skip_for_now": "Bỏ qua bây giờ", + "mail_subject": "{{campaignName}} – nhận thưởng", + "mail_body": "Mã trúng thưởng của tôi: {{code}}", + "winning_code": "Mã trúng thưởng", + "close_a11y": "Đóng" } }, "time": { diff --git a/locales/languages/zh.json b/locales/languages/zh.json index bee8f24db28..f7a0a2db289 100644 --- a/locales/languages/zh.json +++ b/locales/languages/zh.json @@ -8658,29 +8658,18 @@ "retry_button": "重试", "refreshing": "正在刷新……" }, - "ondo_campaign_winning": { - "you_won": "您已获胜", - "rank_label": "第 {{place}} 名", - "email_instructions": "请将您的代码发送至 ondocampaign@consensys.net 领取奖品。", - "open_mail": "打开邮件", - "skip_for_now": "暂时跳过", - "mail_subject": "领取 Ondo 活动奖品", - "mail_body": "我的获奖码:{{code}}", - "winning_code": "获奖码", - "close_a11y": "关闭" - }, - "ondo_outcome_banner": { + "campaign_outcome_banner": { "winner_pending": { - "title": "您已获胜", - "description": "立即领取您的奖品。", - "a11y": "打开获奖者详情" + "title": "您中奖了!", + "description": "验证您的中奖代码,并在今天领取奖品。", + "a11y": "查看详情" }, "participant_pending": { "title": "活动已结束。", "description": "我们正在确定结果。请稍后再来查看。" }, "participant_finalized": { - "title": "活动结果已出炉", + "title": "活动结果已公布。", "description": "您这次未中奖。查看排行榜了解您的最终排名。" }, "winner_finalized": { @@ -8688,17 +8677,28 @@ "description": "您的奖励正在发放中——我们将很快联系您。" } }, - "ondo_outcome_toast": { - "winner_verify": { + "campaign_outcome_toast": { + "winner": { "title": "您已获胜 💰", - "description": "领取您在 {{campaignName}} 中的奖品", + "description": "今天从 {{campaignName}} 领取您的奖品。", "cta": "查看详情" }, - "participant_no_winner": { - "title": "结果已揭晓。🏅", - "description": "查看您在 {{campaignName}} 中的排名", + "non_winner": { + "title": "结果已公布 🏅", + "description": "查看您在 {{campaignName}} 中的排名。", "cta": "查看" } + }, + "campaign_winning": { + "you_won": "您已获胜", + "rank_label": "第 {{place}} 名", + "email_instructions": "请将您的代码发送至 {{email}} 领取奖品。", + "open_mail": "打开邮件", + "skip_for_now": "暂时跳过", + "mail_subject": "{{campaignName}} 奖品领取", + "mail_body": "我的获奖码:{{code}}", + "winning_code": "获奖码", + "close_a11y": "关闭" } }, "time": { From 794a99784ca7e7c1e6c423979e8deaf478764a07 Mon Sep 17 00:00:00 2001 From: Priya Date: Wed, 6 May 2026 12:36:10 +0200 Subject: [PATCH 7/9] test: add system tests tags (#29759) ## **Description** ## **Changelog** CHANGELOG entry: ## **Related issues** Fixes: ## **Manual testing steps** ```gherkin Feature: my feature name Scenario: user [verb for user action] Given [describe expected initial app state] When user [verb for user action] Then [describe expected outcome] ``` ## **Screenshots/Recordings** ### **Before** ### **After** ## **Pre-merge author checklist** - [ ] 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). - [ ] I've completed the PR template to the best of my ability - [ ] I've included tests if applicable - [ ] I've documented my code using [JSDoc](https://jsdoc.app/) format if applicable - [ ] 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) - [ ] I've tested on Android - Ideally on a mid-range device; emulator is acceptable - [ ] 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 - [ ] 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** > Low risk: changes are confined to Playwright test tagging/configuration and documentation, primarily affecting which tests are selected by `--grep` and CI scripts. > > **Overview** > Introduces **test-type tags** `@Performance` and `@System` (in `tests/tags.performance.js`) and updates performance specs to include these tags in `test.describe()` names so Playwright configs can filter suites via `grep`. > > Adds new `package.json` scripts to run **system tests on local Android emulators and iOS simulators** using `tests/playwright.system-emulator.config.ts`, and updates MM Connect and other performance/system specs to be grouped under the new tagging convention. Documentation in `tests/performance/README.md` is expanded to explain the new tag types, conventions, and examples. > > Reviewed by [Cursor Bugbot](https://cursor.com/bugbot) for commit 28088c1c6be5d538e145539c5afe6e0539445b0b. Bugbot is set up for automated code reviews on this repo. Configure [here](https://www.cursor.com/dashboard/bugbot). --- package.json | 4 + tests/performance/README.md | 58 +- .../performance/login/asset-balances.spec.ts | 4 +- tests/performance/login/asset-view.spec.ts | 73 +-- .../login/cross-chain-swap-flow.spec.ts | 9 +- tests/performance/login/eth-swap-flow.spec.ts | 9 +- .../login/import-multiple-srps.spec.ts | 159 +++--- .../launch-times/cold-start-to-login.spec.ts | 76 +-- .../warm-start-login-to-wallet.spec.ts | 93 +-- .../launch-times/warm-start-to-login.spec.ts | 75 +-- .../performance/login/perps-add-funds.spec.ts | 4 +- .../login/perps-position-management.spec.ts | 4 +- .../predict/predict-available-balance.spec.ts | 4 +- .../login/predict/predict-deposit.spec.ts | 4 +- .../predict/predict-market-details.spec.ts | 4 +- .../mm-connect/connection-evm-account.spec.ts | 271 ++++----- .../connection-evm-rejection.spec.ts | 357 ++++++------ .../connection-evm-session-timeout.spec.ts | 287 +++++----- .../mm-connect/connection-evm-sign.spec.ts | 359 ++++++------ .../mm-connect/connection-multichain.spec.ts | 129 +++-- .../connection-multiclient-resilience.spec.ts | 433 +++++++------- .../mm-connect/connection-multiclient.spec.ts | 531 +++++++++--------- .../connection-wagmi-chains.spec.ts | 417 +++++++------- .../mm-connect/connection-wagmi.spec.ts | 405 ++++++------- .../mm-connect/legacy-evm-rn-connect.spec.ts | 313 ++++++----- .../mm-connect/multichain-rn-evm.spec.ts | 263 ++++----- .../mm-connect/multichain-rn-solana.spec.ts | 235 ++++---- .../onboarding/import-wallet.spec.ts | 4 +- .../imported-wallet-account-creation.spec.ts | 3 +- .../cold-start-after-wallet-import.spec.ts | 3 +- .../cold-start-to-onboarding.spec.ts | 3 +- .../new-wallet-account-creation.spec.ts | 4 +- .../seedless-apple-onboarding.spec.ts | 8 +- .../seedless-google-onboarding.spec.ts | 8 +- tests/playwright.config.ts | 1 + tests/playwright.system-emulator.config.ts | 127 +++++ tests/playwright.system.config.ts | 1 + tests/tags.performance.js | 30 +- 38 files changed, 2533 insertions(+), 2239 deletions(-) create mode 100644 tests/playwright.system-emulator.config.ts diff --git a/package.json b/package.json index 333265fecb5..ae9cdbd7feb 100644 --- a/package.json +++ b/package.json @@ -129,6 +129,10 @@ "run-system-tests:ios-login": "yarn playwright test --project system-ios-login --config tests/playwright.system.config.ts", "run-system-tests:ios-onboarding": "yarn playwright test --project system-ios-onboarding --config tests/playwright.system.config.ts", "run-system-tests:all": "yarn playwright test --config tests/playwright.system.config.ts", + "run-system-tests:android-login-emu": "yarn playwright test --project system-android-login-emu --config tests/playwright.system-emulator.config.ts", + "run-system-tests:android-onboarding-emu": "yarn playwright test --project system-android-onboarding-emu --config tests/playwright.system-emulator.config.ts", + "run-system-tests:ios-login-sim": "yarn playwright test --project system-ios-login-sim --config tests/playwright.system-emulator.config.ts", + "run-system-tests:ios-onboarding-sim": "yarn playwright test --project system-ios-onboarding-sim --config tests/playwright.system-emulator.config.ts", "test:depcheck": "yarn depcheck", "test:tgz-check": "./scripts/tgz-check.sh", "test:attribution-check": "./scripts/attributions-check.sh", diff --git a/tests/performance/README.md b/tests/performance/README.md index 4e5a6ae53ae..4190b2967a1 100644 --- a/tests/performance/README.md +++ b/tests/performance/README.md @@ -218,7 +218,22 @@ npx playwright test --grep "@PerformanceLogin.*@PerformanceLaunch" --project and ## Test Tags -Tests are tagged with area-specific tags defined in `tests/tags.performance.js`. These tags allow for selective test execution based on which areas of the app are affected by code changes. +Tags are defined in `tests/tags.performance.js` and embedded in `test.describe()` names. They are runner-agnostic — any runner with `--grep` support can filter by them. + +### Test Type Tags + +These tags control which Playwright config picks up a test: + +| Tag | Description | Config that filters for it | +| -------------- | ---------------------------------------------------------------------- | ------------------------------------------------------------------------------------------ | +| `@Performance` | Test measures performance (uses `TimerHelper`, quality gates enforced) | `playwright.config.ts` (`grep: /@Performance/`) | +| `@System` | Test verifies functionality (no quality gates or metrics) | `playwright.system.config.ts` / `playwright.system-emulator.config.ts` (`grep: /@System/`) | + +Most existing tests are tagged with **both** `@Performance @System` — they measure perf and also serve as system smoke tests. A test can use just one tag if it should only run in one suite. + +### Area Tags + +These tags categorize tests by feature area and can be used with `--grep` for ad-hoc filtering: | Tag | Description | | -------------------------- | ------------------------------------------------------------- | @@ -232,18 +247,41 @@ Tests are tagged with area-specific tags defined in `tests/tags.performance.js`. | `@PerformancePredict` | Predict market performance (market list, details, deposits) | | `@PerformancePreps` | Perpetuals trading performance (positions, add funds, orders) | -Tags are imported into test files from `tests/tags.performance.js`: +### Tagging Convention + +Import type tags and area tags from `tests/tags.performance.js`: ```typescript -import { PerformanceLogin, PerformanceSwaps } from '../../tags.performance.js'; +import { + Performance, + System, + PerformanceLogin, + PerformanceSwaps, +} from '../../tags.performance.js'; -perfTest.describe(`${PerformanceLogin} ${PerformanceSwaps}`, () => { - perfTest( - 'Swap flow performance', - async ({ currentDeviceDetails, driver, performanceTracker }, testInfo) => { - // test implementation - }, - ); +// Both perf and system test (most common): +perfTest.describe( + `${Performance} ${System} ${PerformanceLogin} ${PerformanceSwaps}`, + () => { + perfTest( + 'Swap flow performance', + async ( + { currentDeviceDetails, driver, performanceTracker }, + testInfo, + ) => { + // test implementation with TimerHelper and thresholds + }, + ); + }, +); + +// System-only test (functional verification, no perf measurement): +test.describe(`${System} ${PerformanceLogin}`, () => { + test('Verify wallet loads after login', async ({ driver }) => { + // No TimerHelper — pure functional check + await loginToAppPlaywright(); + await WalletView.waitForAccountName('Account 1'); + }); }); ``` diff --git a/tests/performance/login/asset-balances.spec.ts b/tests/performance/login/asset-balances.spec.ts index 40f65f13d5a..e1266b2b634 100644 --- a/tests/performance/login/asset-balances.spec.ts +++ b/tests/performance/login/asset-balances.spec.ts @@ -6,12 +6,14 @@ import WalletView from '../../page-objects/wallet/WalletView'; import { loginToAppPlaywright } from '../../flows/wallet.flow'; import { + Performance, + System, PerformanceLogin, PerformanceAssetLoading, } from '../../tags.performance.js'; /* Scenario: Aggregated Balance Loading Time, SRP 1 + SRP 2 + SRP 3 */ -test.describe(`${PerformanceLogin} ${PerformanceAssetLoading}`, () => { +test.describe(`${Performance} ${System} ${PerformanceLogin} ${PerformanceAssetLoading}`, () => { test( 'Aggregated Balance Loading Time, SRP 1 + SRP 2 + SRP 3', { tag: '@assets-dev-team' }, diff --git a/tests/performance/login/asset-view.spec.ts b/tests/performance/login/asset-view.spec.ts index 9d1eb1dffe6..588245772d5 100644 --- a/tests/performance/login/asset-view.spec.ts +++ b/tests/performance/login/asset-view.spec.ts @@ -5,45 +5,52 @@ import { asPlaywrightElement, PlaywrightAssertions } from '../../framework'; import WalletView from '../../page-objects/wallet/WalletView'; import TokenOverview from '../../page-objects/wallet/TokenOverview'; import { + Performance, PerformanceLogin, PerformanceAssetLoading, } from '../../tags.performance.js'; /* Scenario 8: Asset View, SRP 1 + SRP 2 + SRP 3 */ -perfTest.describe(`${PerformanceLogin} ${PerformanceAssetLoading}`, () => { - perfTest( - 'Asset View, SRP 1 + SRP 2 + SRP 3', - { tag: '@assets-dev-team' }, - async ({ currentDeviceDetails, driver, performanceTracker }, testInfo) => { - await loginToAppPlaywright(); +perfTest.describe( + `${Performance} ${PerformanceLogin} ${PerformanceAssetLoading}`, + () => { + perfTest( + 'Asset View, SRP 1 + SRP 2 + SRP 3', + { tag: '@assets-dev-team' }, + async ( + { currentDeviceDetails, driver, performanceTracker }, + testInfo, + ) => { + await loginToAppPlaywright(); - const assetViewScreen = new TimerHelper( - 'Time since the user clicks on the asset view button until the user sees the token overview screen', - { ios: 600, android: 4500 }, - currentDeviceDetails.platform, - ); + const assetViewScreen = new TimerHelper( + 'Time since the user clicks on the asset view button until the user sees the token overview screen', + { ios: 600, android: 4500 }, + currentDeviceDetails.platform, + ); - await WalletView.tapOnTokensSection(); - await WalletView.tapOnToken('USDC'); + await WalletView.tapOnTokensSection(); + await WalletView.tapOnToken('USDC'); - await assetViewScreen.measure(async () => { - await PlaywrightAssertions.expectElementToBeVisible( - asPlaywrightElement(TokenOverview.container), - ); - await PlaywrightAssertions.expectElementToBeVisible( - asPlaywrightElement(TokenOverview.sendButton), - ); - // Replicating the logic of the old spec to wait for the todays change to be visible isTodaysChangeVisible method in the TokenOverview wdio screen object - await PlaywrightAssertions.expectElementToBeVisibleWithSettle( - asPlaywrightElement(TokenOverview.todaysChange), - { - timeout: 10000, - settleMs: 500, - }, - ); - }); + await assetViewScreen.measure(async () => { + await PlaywrightAssertions.expectElementToBeVisible( + asPlaywrightElement(TokenOverview.container), + ); + await PlaywrightAssertions.expectElementToBeVisible( + asPlaywrightElement(TokenOverview.sendButton), + ); + // Replicating the logic of the old spec to wait for the todays change to be visible isTodaysChangeVisible method in the TokenOverview wdio screen object + await PlaywrightAssertions.expectElementToBeVisibleWithSettle( + asPlaywrightElement(TokenOverview.todaysChange), + { + timeout: 10000, + settleMs: 500, + }, + ); + }); - performanceTracker.addTimer(assetViewScreen); - }, - ); -}); + performanceTracker.addTimer(assetViewScreen); + }, + ); + }, +); diff --git a/tests/performance/login/cross-chain-swap-flow.spec.ts b/tests/performance/login/cross-chain-swap-flow.spec.ts index edd434c2a84..8d30e4f2dd3 100644 --- a/tests/performance/login/cross-chain-swap-flow.spec.ts +++ b/tests/performance/login/cross-chain-swap-flow.spec.ts @@ -1,12 +1,17 @@ import { test } from '../../framework/fixture'; import TimerHelper from '../../framework/TimerHelper.js'; -import { PerformanceLogin, PerformanceSwaps } from '../../tags.performance.js'; +import { + Performance, + System, + PerformanceLogin, + PerformanceSwaps, +} from '../../tags.performance.js'; import { loginToAppPlaywright } from '../../flows/wallet.flow.js'; import WalletView from '../../page-objects/wallet/WalletView.js'; import QuoteView from '../../page-objects/swaps/QuoteView.js'; /* Scenario 7: Cross-chain swap flow - ETH to SOL - 50+ accounts, SRP 1 + SRP 2 + SRP 3 */ -test.describe(`${PerformanceLogin} ${PerformanceSwaps}`, () => { +test.describe(`${Performance} ${System} ${PerformanceLogin} ${PerformanceSwaps}`, () => { test( 'Cross-chain swap flow - ETH to SOL - 50+ accounts, SRP 1 + SRP 2 + SRP 3', { tag: '@swap-bridge-dev-team' }, diff --git a/tests/performance/login/eth-swap-flow.spec.ts b/tests/performance/login/eth-swap-flow.spec.ts index 1e1db2e94af..98250f5034b 100644 --- a/tests/performance/login/eth-swap-flow.spec.ts +++ b/tests/performance/login/eth-swap-flow.spec.ts @@ -1,12 +1,17 @@ import { test } from '../../framework/fixture'; import TimerHelper from '../../framework/TimerHelper'; -import { PerformanceLogin, PerformanceSwaps } from '../../tags.performance.js'; +import { + Performance, + System, + PerformanceLogin, + PerformanceSwaps, +} from '../../tags.performance.js'; import { loginToAppPlaywright } from '../../flows/wallet.flow'; import WalletView from '../../page-objects/wallet/WalletView'; import QuoteView from '../../page-objects/swaps/QuoteView'; /* Scenario 6: Swap flow - ETH to LINK, SRP 1 + SRP 2 + SRP 3 */ -test.describe(`${PerformanceLogin} ${PerformanceSwaps}`, () => { +test.describe(`${Performance} ${System} ${PerformanceLogin} ${PerformanceSwaps}`, () => { test( 'Swap flow - ETH to LINK, SRP 1 + SRP 2 + SRP 3', { tag: '@swap-bridge-dev-team' }, diff --git a/tests/performance/login/import-multiple-srps.spec.ts b/tests/performance/login/import-multiple-srps.spec.ts index ff1b94c7a2d..24942bd68ae 100644 --- a/tests/performance/login/import-multiple-srps.spec.ts +++ b/tests/performance/login/import-multiple-srps.spec.ts @@ -7,95 +7,100 @@ import AddAccountBottomSheet from '../../page-objects/wallet/AddAccountBottomShe import AccountListBottomSheet from '../../page-objects/wallet/AccountListBottomSheet'; import WalletView from '../../page-objects/wallet/WalletView'; import { + Performance, + System, PerformanceAccountList, PerformanceLogin, } from '../../tags.performance.js'; import PlaywrightGestures from '../../framework/PlaywrightGestures'; /* Scenario 4: Import SRP with +50 accounts, SRP 1, SRP 2, SRP 3 */ -perfTest.describe(`${PerformanceLogin} ${PerformanceAccountList}`, () => { - perfTest.setTimeout(30 * 60 * 1000); +perfTest.describe( + `${Performance} ${System} ${PerformanceLogin} ${PerformanceAccountList}`, + () => { + perfTest.setTimeout(30 * 60 * 1000); - perfTest( - 'Import SRP with +50 accounts, SRP 1, SRP 2, SRP 3', - { tag: '@accounts-team' }, - async ({ currentDeviceDetails, driver, performanceTracker }) => { - const importedSrp = process.env.TEST_SRP_2; + perfTest( + 'Import SRP with +50 accounts, SRP 1, SRP 2, SRP 3', + { tag: '@accounts-team' }, + async ({ currentDeviceDetails, driver, performanceTracker }) => { + const importedSrp = process.env.TEST_SRP_2; - if (!importedSrp) { - throw new Error( - 'TEST_SRP_2 environment variable is required for this performance test.', - ); - } - - await loginToAppPlaywright(); + if (!importedSrp) { + throw new Error( + 'TEST_SRP_2 environment variable is required for this performance test.', + ); + } - const accountListTimer = new TimerHelper( - 'Time since the user clicks on "Account list" button until the account list is visible', - { ios: 2500, android: 3000 }, - currentDeviceDetails.platform, - ); - const addAccountTimer = new TimerHelper( - 'Time since the user clicks on "Add account" button until the next modal is visible', - { ios: 1000, android: 1700 }, - currentDeviceDetails.platform, - ); - const importSrpTimer = new TimerHelper( - 'Time since the user clicks on "Import SRP" button until SRP field is displayed', - { ios: 1700, android: 1700 }, - currentDeviceDetails.platform, - ); - const walletReadyTimer = new TimerHelper( - 'Time since the user clicks on "Continue" button on SRP screen until Wallet main screen is visible', - { ios: 5000, android: 2000 }, - currentDeviceDetails.platform, - ); + await loginToAppPlaywright(); - await WalletView.tapIdenticon(); - await accountListTimer.measure(async () => { - await PlaywrightAssertions.expectElementToBeVisible( - asPlaywrightElement(AccountListBottomSheet.accountList), - { - description: 'Account list should be visible', - }, + const accountListTimer = new TimerHelper( + 'Time since the user clicks on "Account list" button until the account list is visible', + { ios: 2500, android: 3000 }, + currentDeviceDetails.platform, ); - }); - - await AccountListBottomSheet.waitForAccountSyncToComplete(); - await AccountListBottomSheet.tapAddAccountButton(); - await addAccountTimer.measure(async () => { - await PlaywrightAssertions.expectElementToBeVisible( - asPlaywrightElement(AddAccountBottomSheet.importSrpButton), - { - description: 'Add account bottom sheet should be visible', - }, + const addAccountTimer = new TimerHelper( + 'Time since the user clicks on "Add account" button until the next modal is visible', + { ios: 1000, android: 1700 }, + currentDeviceDetails.platform, + ); + const importSrpTimer = new TimerHelper( + 'Time since the user clicks on "Import SRP" button until SRP field is displayed', + { ios: 1700, android: 1700 }, + currentDeviceDetails.platform, + ); + const walletReadyTimer = new TimerHelper( + 'Time since the user clicks on "Continue" button on SRP screen until Wallet main screen is visible', + { ios: 5000, android: 2000 }, + currentDeviceDetails.platform, ); - }); - await AddAccountBottomSheet.tapImportSrp(); - await importSrpTimer.measure(async () => { - await ImportWalletView.isScreenTitleVisible(false); - }); + await WalletView.tapIdenticon(); + await accountListTimer.measure(async () => { + await PlaywrightAssertions.expectElementToBeVisible( + asPlaywrightElement(AccountListBottomSheet.accountList), + { + description: 'Account list should be visible', + }, + ); + }); - await ImportWalletView.typeSecretRecoveryPhrase(importedSrp, false); - await PlaywrightGestures.hideKeyboard(); - await ImportWalletView.tapContinueButton(false); + await AccountListBottomSheet.waitForAccountSyncToComplete(); + await AccountListBottomSheet.tapAddAccountButton(); + await addAccountTimer.measure(async () => { + await PlaywrightAssertions.expectElementToBeVisible( + asPlaywrightElement(AddAccountBottomSheet.importSrpButton), + { + description: 'Add account bottom sheet should be visible', + }, + ); + }); - await walletReadyTimer.measure(async () => { - await PlaywrightAssertions.expectElementToBeVisible( - asPlaywrightElement(WalletView.accountIcon), - { - description: - 'Wallet main screen should be visible after importing SRP', - }, - ); - }); + await AddAccountBottomSheet.tapImportSrp(); + await importSrpTimer.measure(async () => { + await ImportWalletView.isScreenTitleVisible(false); + }); + + await ImportWalletView.typeSecretRecoveryPhrase(importedSrp, false); + await PlaywrightGestures.hideKeyboard(); + await ImportWalletView.tapContinueButton(false); - performanceTracker.addTimers( - accountListTimer, - addAccountTimer, - importSrpTimer, - walletReadyTimer, - ); - }, - ); -}); + await walletReadyTimer.measure(async () => { + await PlaywrightAssertions.expectElementToBeVisible( + asPlaywrightElement(WalletView.accountIcon), + { + description: + 'Wallet main screen should be visible after importing SRP', + }, + ); + }); + + performanceTracker.addTimers( + accountListTimer, + addAccountTimer, + importSrpTimer, + walletReadyTimer, + ); + }, + ); + }, +); diff --git a/tests/performance/login/launch-times/cold-start-to-login.spec.ts b/tests/performance/login/launch-times/cold-start-to-login.spec.ts index 3630db824a9..08c59502c1d 100644 --- a/tests/performance/login/launch-times/cold-start-to-login.spec.ts +++ b/tests/performance/login/launch-times/cold-start-to-login.spec.ts @@ -9,6 +9,7 @@ import { loginToAppPlaywright } from '../../../flows/wallet.flow'; import LoginView from '../../../page-objects/wallet/LoginView'; import WalletView from '../../../page-objects/wallet/WalletView'; import { + Performance, PerformanceLogin, PerformanceLaunch, } from '../../../tags.performance.js'; @@ -25,43 +26,50 @@ import { * The test measures: * 1. Time to relaunch the app and display the login screen */ -perfTest.describe(`${PerformanceLogin} ${PerformanceLaunch}`, () => { - perfTest( - 'Cold Start: Measure ColdStart To Login Screen', - { tag: '@metamask-mobile-platform' }, - async ({ currentDeviceDetails, driver, performanceTracker }, testInfo) => { - await loginToAppPlaywright(); - await PlaywrightAssertions.expectElementToBeVisible( - asPlaywrightElement(WalletView.accountIcon), - { - timeout: 15000, - description: 'Wallet account icon should be visible before relaunch', - }, - ); - await WalletView.waitForBalanceToStabilize(); - await PlaywrightGestures.terminateApp(currentDeviceDetails); - - const timer1 = new TimerHelper( - 'Time since the the app is launched, until login screen appears', - { ios: 3000, android: 4000 }, - currentDeviceDetails.platform, - ); - - await PlaywrightGestures.activateApp(currentDeviceDetails); - await timer1.measure(async () => { +perfTest.describe( + `${Performance} ${PerformanceLogin} ${PerformanceLaunch}`, + () => { + perfTest( + 'Cold Start: Measure ColdStart To Login Screen', + { tag: '@metamask-mobile-platform' }, + async ( + { currentDeviceDetails, driver, performanceTracker }, + testInfo, + ) => { + await loginToAppPlaywright(); await PlaywrightAssertions.expectElementToBeVisible( - asPlaywrightElement(LoginView.container), + asPlaywrightElement(WalletView.accountIcon), { - description: 'Login title should be visible', + timeout: 15000, + description: + 'Wallet account icon should be visible before relaunch', }, ); - }); + await WalletView.waitForBalanceToStabilize(); + await PlaywrightGestures.terminateApp(currentDeviceDetails); + + const timer1 = new TimerHelper( + 'Time since the the app is launched, until login screen appears', + { ios: 3000, android: 4000 }, + currentDeviceDetails.platform, + ); + + await PlaywrightGestures.activateApp(currentDeviceDetails); + await timer1.measure(async () => { + await PlaywrightAssertions.expectElementToBeVisible( + asPlaywrightElement(LoginView.container), + { + description: 'Login title should be visible', + }, + ); + }); - performanceTracker.addTimers(timer1); + performanceTracker.addTimers(timer1); - console.log('Cold Start to Login Screen Performance Test completed'); - console.log(`Cold Start to Login Screen: ${timer1.getDuration()}ms`); - console.log(`Total Time: ${timer1.getDuration() ?? 0}ms`); - }, - ); -}); + console.log('Cold Start to Login Screen Performance Test completed'); + console.log(`Cold Start to Login Screen: ${timer1.getDuration()}ms`); + console.log(`Total Time: ${timer1.getDuration() ?? 0}ms`); + }, + ); + }, +); diff --git a/tests/performance/login/launch-times/warm-start-login-to-wallet.spec.ts b/tests/performance/login/launch-times/warm-start-login-to-wallet.spec.ts index bd5deae3b07..a0a610340dc 100644 --- a/tests/performance/login/launch-times/warm-start-login-to-wallet.spec.ts +++ b/tests/performance/login/launch-times/warm-start-login-to-wallet.spec.ts @@ -10,6 +10,7 @@ import { getPasswordForScenario } from '../../../framework/utils/TestConstants.j import LoginView from '../../../page-objects/wallet/LoginView'; import WalletView from '../../../page-objects/wallet/WalletView'; import { + Performance, PerformanceLogin, PerformanceLaunch, } from '../../../tags.performance.js'; @@ -26,53 +27,59 @@ import { * The test measures: * 1. Time to tap Unlock and display the wallet screen again */ -perfTest.describe(`${PerformanceLogin} ${PerformanceLaunch}`, () => { - perfTest( - 'Measure Warm Start: Login To Wallet Screen', - { tag: '@metamask-mobile-platform' }, - async ({ currentDeviceDetails, driver, performanceTracker }, testInfo) => { - await loginToAppPlaywright(); - await PlaywrightAssertions.expectElementToBeVisible( - asPlaywrightElement(WalletView.totalBalance), - { - description: - 'Wallet account icon should be visible before warm start', - }, - ); - - await PlaywrightGestures.backgroundApp(35); - await PlaywrightGestures.activateApp(currentDeviceDetails); - await PlaywrightAssertions.expectElementToBeVisible( - asPlaywrightElement(LoginView.passwordInput), - { - description: 'Login title should be visible', - }, - ); - const loginPassword = getPasswordForScenario('login'); - await LoginView.enterPassword(loginPassword); - - const timer1 = new TimerHelper( - 'Time since the user clicks on unlock button, until the app unlocks', - { ios: 2500, android: 2500 }, - currentDeviceDetails.platform, - ); +perfTest.describe( + `${Performance} ${PerformanceLogin} ${PerformanceLaunch}`, + () => { + perfTest( + 'Measure Warm Start: Login To Wallet Screen', + { tag: '@metamask-mobile-platform' }, + async ( + { currentDeviceDetails, driver, performanceTracker }, + testInfo, + ) => { + await loginToAppPlaywright(); + await PlaywrightAssertions.expectElementToBeVisible( + asPlaywrightElement(WalletView.totalBalance), + { + description: + 'Wallet account icon should be visible before warm start', + }, + ); - await LoginView.tapLoginButton(); - await timer1.measure(async () => { + await PlaywrightGestures.backgroundApp(35); + await PlaywrightGestures.activateApp(currentDeviceDetails); await PlaywrightAssertions.expectElementToBeVisible( - asPlaywrightElement(WalletView.container), + asPlaywrightElement(LoginView.passwordInput), { - description: 'Wallet balance should be visible', + description: 'Login title should be visible', }, ); - // await WalletView.waitForBalanceToStabilize(); - }); + const loginPassword = getPasswordForScenario('login'); + await LoginView.enterPassword(loginPassword); + + const timer1 = new TimerHelper( + 'Time since the user clicks on unlock button, until the app unlocks', + { ios: 2500, android: 2500 }, + currentDeviceDetails.platform, + ); + + await LoginView.tapLoginButton(); + await timer1.measure(async () => { + await PlaywrightAssertions.expectElementToBeVisible( + asPlaywrightElement(WalletView.container), + { + description: 'Wallet balance should be visible', + }, + ); + // await WalletView.waitForBalanceToStabilize(); + }); - performanceTracker.addTimers(timer1); + performanceTracker.addTimers(timer1); - console.log('Warm Start Login to Wallet Performance Test completed'); - console.log(`Warm Start Login to Wallet: ${timer1.getDuration()}ms`); - console.log(`Total Time: ${timer1.getDuration() ?? 0}ms`); - }, - ); -}); + console.log('Warm Start Login to Wallet Performance Test completed'); + console.log(`Warm Start Login to Wallet: ${timer1.getDuration()}ms`); + console.log(`Total Time: ${timer1.getDuration() ?? 0}ms`); + }, + ); + }, +); diff --git a/tests/performance/login/launch-times/warm-start-to-login.spec.ts b/tests/performance/login/launch-times/warm-start-to-login.spec.ts index e9189ed8cb9..b8f09a0a95f 100644 --- a/tests/performance/login/launch-times/warm-start-to-login.spec.ts +++ b/tests/performance/login/launch-times/warm-start-to-login.spec.ts @@ -9,6 +9,7 @@ import { loginToAppPlaywright } from '../../../flows/wallet.flow'; import LoginView from '../../../page-objects/wallet/LoginView'; import WalletView from '../../../page-objects/wallet/WalletView'; import { + Performance, PerformanceLogin, PerformanceLaunch, } from '../../../tags.performance.js'; @@ -25,43 +26,49 @@ import { * The test measures: * 1. Time to foreground the app and display the login screen */ -perfTest.describe(`${PerformanceLogin} ${PerformanceLaunch}`, () => { - perfTest( - 'Measure Warm Start: Warm Start to Login Screen', - { tag: '@metamask-mobile-platform' }, - async ({ currentDeviceDetails, driver, performanceTracker }, testInfo) => { - await loginToAppPlaywright(); - await PlaywrightAssertions.expectElementToBeVisible( - asPlaywrightElement(WalletView.accountIcon), - { - description: - 'Wallet account icon should be visible before warm start', - }, - ); - - const timer1 = new TimerHelper( - 'Time since the user open the app again and the login screen appears', - { ios: 2500, android: 3000 }, - currentDeviceDetails.platform, - ); - - await PlaywrightGestures.backgroundApp(35); - await PlaywrightGestures.activateApp(currentDeviceDetails); - - await timer1.measure(async () => { +perfTest.describe( + `${Performance} ${PerformanceLogin} ${PerformanceLaunch}`, + () => { + perfTest( + 'Measure Warm Start: Warm Start to Login Screen', + { tag: '@metamask-mobile-platform' }, + async ( + { currentDeviceDetails, driver, performanceTracker }, + testInfo, + ) => { + await loginToAppPlaywright(); await PlaywrightAssertions.expectElementToBeVisible( - asPlaywrightElement(LoginView.container), + asPlaywrightElement(WalletView.accountIcon), { - description: 'Login title should be visible', + description: + 'Wallet account icon should be visible before warm start', }, ); - }); - performanceTracker.addTimers(timer1); + const timer1 = new TimerHelper( + 'Time since the user open the app again and the login screen appears', + { ios: 2500, android: 3000 }, + currentDeviceDetails.platform, + ); + + await PlaywrightGestures.backgroundApp(35); + await PlaywrightGestures.activateApp(currentDeviceDetails); + + await timer1.measure(async () => { + await PlaywrightAssertions.expectElementToBeVisible( + asPlaywrightElement(LoginView.container), + { + description: 'Login title should be visible', + }, + ); + }); + + performanceTracker.addTimers(timer1); - console.log('Warm Start to Login Screen Performance Test completed'); - console.log(`Warm Start to Login Screen: ${timer1.getDuration()}ms`); - console.log(`Total Time: ${timer1.getDuration() ?? 0}ms`); - }, - ); -}); + console.log('Warm Start to Login Screen Performance Test completed'); + console.log(`Warm Start to Login Screen: ${timer1.getDuration()}ms`); + console.log(`Total Time: ${timer1.getDuration() ?? 0}ms`); + }, + ); + }, +); diff --git a/tests/performance/login/perps-add-funds.spec.ts b/tests/performance/login/perps-add-funds.spec.ts index d85c6d22195..90076f244f8 100644 --- a/tests/performance/login/perps-add-funds.spec.ts +++ b/tests/performance/login/perps-add-funds.spec.ts @@ -1,6 +1,6 @@ import { test } from '../../framework/fixture'; import TimerHelper from '../../framework/TimerHelper'; -import { PerformancePreps } from '../../tags.performance.js'; +import { Performance, PerformancePreps } from '../../tags.performance.js'; import { loginToAppPlaywright } from '../../flows/wallet.flow'; import TabBarComponent from '../../page-objects/wallet/TabBarComponent'; import PerpsOnboarding from '../../page-objects/Perps/PerpsOnboarding'; @@ -10,7 +10,7 @@ import PlaywrightAssertions from '../../framework/PlaywrightAssertions'; import { asPlaywrightElement } from '../../framework/EncapsulatedElement'; /* Scenario 5: Perps add funds */ -test.describe(PerformancePreps, () => { +test.describe(`${Performance} ${PerformancePreps}`, () => { test( 'Perps add funds', { tag: '@mm-perps-engineering-team' }, diff --git a/tests/performance/login/perps-position-management.spec.ts b/tests/performance/login/perps-position-management.spec.ts index e04159d5b39..2e0622a0893 100644 --- a/tests/performance/login/perps-position-management.spec.ts +++ b/tests/performance/login/perps-position-management.spec.ts @@ -1,7 +1,7 @@ import { test } from '../../framework/fixture'; import TimerHelper from '../../framework/TimerHelper'; -import { PerformancePreps } from '../../tags.performance.js'; +import { Performance, PerformancePreps } from '../../tags.performance.js'; import { loginToAppPlaywright, selectAccountByDevice, @@ -21,7 +21,7 @@ import PlaywrightAssertions from '../../framework/PlaywrightAssertions'; import { asPlaywrightElement } from '../../framework/EncapsulatedElement'; /* Scenario 5: Perps onboarding + add funds 10 USD ARB.USDC + Open Position + Close Position */ -test.describe(PerformancePreps, () => { +test.describe(`${Performance} ${PerformancePreps}`, () => { test( 'Perps open position and close it', { tag: '@mm-perps-engineering-team' }, diff --git a/tests/performance/login/predict/predict-available-balance.spec.ts b/tests/performance/login/predict/predict-available-balance.spec.ts index 0e44e0a7da0..3d23de4dbac 100644 --- a/tests/performance/login/predict/predict-available-balance.spec.ts +++ b/tests/performance/login/predict/predict-available-balance.spec.ts @@ -5,7 +5,7 @@ import { asPlaywrightElement, PlaywrightAssertions } from '../../../framework'; import TabBarComponent from '../../../page-objects/wallet/TabBarComponent'; import WalletActionsBottomSheet from '../../../page-objects/wallet/WalletActionsBottomSheet'; import PredictMarketList from '../../../page-objects/Predict/PredictMarketList'; -import { PerformancePredict } from '../../../tags.performance.js'; +import { Performance, PerformancePredict } from '../../../tags.performance.js'; /* * Scenario: Predict Available Balance Performance Test @@ -21,7 +21,7 @@ import { PerformancePredict } from '../../../tags.performance.js'; * 1. Time to navigate to Predict tab * 2. Time to verify available balance info is displayed */ -perfTest.describe(PerformancePredict, () => { +perfTest.describe(`${Performance} ${PerformancePredict}`, () => { perfTest( 'Predict Available Balance - Complete Flow Performance', { tag: '@team-predict' }, diff --git a/tests/performance/login/predict/predict-deposit.spec.ts b/tests/performance/login/predict/predict-deposit.spec.ts index cbb84b92072..64720f868d1 100644 --- a/tests/performance/login/predict/predict-deposit.spec.ts +++ b/tests/performance/login/predict/predict-deposit.spec.ts @@ -6,7 +6,7 @@ import TabBarComponent from '../../../page-objects/wallet/TabBarComponent'; import WalletActionsBottomSheet from '../../../page-objects/wallet/WalletActionsBottomSheet'; import TransactionPayConfirmation from '../../../page-objects/Confirmation/TransactionPayConfirmation'; import PredictMarketList from '../../../page-objects/Predict/PredictMarketList'; -import { PerformancePredict } from '../../../tags.performance.js'; +import { Performance, PerformancePredict } from '../../../tags.performance.js'; /* * Scenario: Predict Deposit Performance Test @@ -24,7 +24,7 @@ import { PerformancePredict } from '../../../tags.performance.js'; * 4. Time to proceed to confirmation screen * 5. Time to verify deposit info (fees, amount) appears */ -perfTest.describe(PerformancePredict, () => { +perfTest.describe(`${Performance} ${PerformancePredict}`, () => { perfTest( 'Predict Deposit - Complete Flow Performance', { tag: '@team-predict' }, diff --git a/tests/performance/login/predict/predict-market-details.spec.ts b/tests/performance/login/predict/predict-market-details.spec.ts index 4d4cdfa951b..7d28502496d 100644 --- a/tests/performance/login/predict/predict-market-details.spec.ts +++ b/tests/performance/login/predict/predict-market-details.spec.ts @@ -6,7 +6,7 @@ import TabBarComponent from '../../../page-objects/wallet/TabBarComponent'; import WalletActionsBottomSheet from '../../../page-objects/wallet/WalletActionsBottomSheet'; import PredictMarketList from '../../../page-objects/Predict/PredictMarketList'; import PredictDetailsPage from '../../../page-objects/Predict/PredictDetailsPage'; -import { PerformancePredict } from '../../../tags.performance.js'; +import { Performance, PerformancePredict } from '../../../tags.performance.js'; /* * Scenario: Predict Market Details Performance Test @@ -23,7 +23,7 @@ import { PerformancePredict } from '../../../tags.performance.js'; * 3. Time to open About tab content * 4. Time to open Outcomes tab content when available */ -perfTest.describe(PerformancePredict, () => { +perfTest.describe(`${Performance} ${PerformancePredict}`, () => { perfTest.setTimeout(10 * 60 * 1000); perfTest( diff --git a/tests/performance/mm-connect/connection-evm-account.spec.ts b/tests/performance/mm-connect/connection-evm-account.spec.ts index 960106682a0..c7747a5abd4 100644 --- a/tests/performance/mm-connect/connection-evm-account.spec.ts +++ b/tests/performance/mm-connect/connection-evm-account.spec.ts @@ -1,4 +1,5 @@ import { test } from '../../framework/fixture'; +import { Performance } from '../../tags.performance.js'; import { loginToAppPlaywright } from '../../flows/wallet.flow'; import WalletView from '../../page-objects/wallet/WalletView'; @@ -38,151 +39,153 @@ const playgroundServer = new DappServer({ dappVariant: DappVariants.BROWSER_PLAYGROUND, }); -test.beforeAll(async () => { - playgroundServer.setServerPort(DAPP_PORT); - await playgroundServer.start(); - await waitForDappServerReady(DAPP_PORT); - setupAdbReverse(DAPP_PORT); -}); - -test.afterAll(async () => { - cleanupAdbReverse(DAPP_PORT); - await playgroundServer.stop(); -}); - -// Test steps (in order): -// -// 1. LOGIN AND NAVIGATE TO DAPP -// - Login to app, ensure account groups finished loading -// - Launch mobile browser and navigate to the playground dapp -// -// 2. CONNECT VIA LEGACY EVM (WITH ACCOUNT 3 ADDED) -// - Tap Connect (Legacy) -// - In MetaMask: tap Edit Accounts, add Account 3, tap Update, tap Connect (cooldown 2s) -// Account 3 must be authorized so MetaMask can switch to it later -// - Assert: connected true, chainId '0x1', active account is Account 1 -// (0x19a7Ad8256ab119655f1D758348501d598fC1C94) -// -// 3. SWITCH TO ACCOUNT 3 IN METAMASK -// - In MetaMask: tap identicon → select Account 3 from the account list -// - Assert: dapp reflects Account 3 as the active account -// (0xE2bEca5CaDC60b61368987728b4229822e6CDa83) -// -// 4. REFRESH BROWSER AND VERIFY ACCOUNT PERSISTS -// - Refresh mobile browser (native action) -// - Assert: connected true, chainId '0x1', active account is still Account 3 -// (0xE2bEca5CaDC60b61368987728b4229822e6CDa83) -// -// 5. PERSONAL SIGN TO VERIFY WALLET-SIDE ACCOUNT -// - Tap personal sign -// - In MetaMask: tap Cancel (cooldown 2s) -// Canceling verifies Account 3 appears as the signer in the modal (wallet-side check) -// - Assert: response value 'rejected' -// -// 6. CLEANUP -// - Tap disconnect to reset dapp state - -test('@metamask/connect-evm - Account switching and wallet-side verification', async ({ - currentDeviceDetails, - driver, -}) => { - const platform = currentDeviceDetails.platform; - const useBrowserStackLocal = - process.env.BROWSERSTACK_LOCAL?.toLowerCase() === 'true'; - const DAPP_URL = useBrowserStackLocal - ? `http://bs-local.com:${DAPP_PORT}` - : getDappUrlForBrowser(platform); - - await PlaywrightContextHelpers.withNativeAction(async () => { - await loginToAppPlaywright(); - await ensureAccountGroupsFinishedLoading(currentDeviceDetails); - await launchMobileBrowser(); - await navigateToDapp(DAPP_URL); - }); - await sleep(5000); - - await PlaywrightContextHelpers.withWebAction(async () => { - await BrowserPlaygroundDapp.tapConnectLegacy(); - }, DAPP_URL); - - await PlaywrightContextHelpers.withNativeAction(async () => { - await AndroidScreenHelpers.tapOpenDeeplinkWithMetaMask(); - await unlockIfLockScreenVisible(); - await DappConnectionModal.tapEditAccountsButton(); - await DappConnectionModal.tapAccountButton('Account 3'); - await DappConnectionModal.tapUpdateAccountsButton(); - await DappConnectionModal.tapConnectButton({ - shouldCooldown: true, - timeToCooldown: 2000, - }); +test.describe(Performance, () => { + test.beforeAll(async () => { + playgroundServer.setServerPort(DAPP_PORT); + await playgroundServer.start(); + await waitForDappServerReady(DAPP_PORT); + setupAdbReverse(DAPP_PORT); }); - await sleep(1000); - await switchToMobileBrowser(); - await sleep(1000); + test.afterAll(async () => { + cleanupAdbReverse(DAPP_PORT); + await playgroundServer.stop(); + }); - await PlaywrightContextHelpers.withWebAction(async () => { - await BrowserPlaygroundDapp.assertConnected(true); - await BrowserPlaygroundDapp.assertChainIdValue('0x1'); - await BrowserPlaygroundDapp.assertActiveAccount(ACCOUNT_1_ADDRESS); - }, DAPP_URL); + // Test steps (in order): + // + // 1. LOGIN AND NAVIGATE TO DAPP + // - Login to app, ensure account groups finished loading + // - Launch mobile browser and navigate to the playground dapp + // + // 2. CONNECT VIA LEGACY EVM (WITH ACCOUNT 3 ADDED) + // - Tap Connect (Legacy) + // - In MetaMask: tap Edit Accounts, add Account 3, tap Update, tap Connect (cooldown 2s) + // Account 3 must be authorized so MetaMask can switch to it later + // - Assert: connected true, chainId '0x1', active account is Account 1 + // (0x19a7Ad8256ab119655f1D758348501d598fC1C94) + // + // 3. SWITCH TO ACCOUNT 3 IN METAMASK + // - In MetaMask: tap identicon → select Account 3 from the account list + // - Assert: dapp reflects Account 3 as the active account + // (0xE2bEca5CaDC60b61368987728b4229822e6CDa83) + // + // 4. REFRESH BROWSER AND VERIFY ACCOUNT PERSISTS + // - Refresh mobile browser (native action) + // - Assert: connected true, chainId '0x1', active account is still Account 3 + // (0xE2bEca5CaDC60b61368987728b4229822e6CDa83) + // + // 5. PERSONAL SIGN TO VERIFY WALLET-SIDE ACCOUNT + // - Tap personal sign + // - In MetaMask: tap Cancel (cooldown 2s) + // Canceling verifies Account 3 appears as the signer in the modal (wallet-side check) + // - Assert: response value 'rejected' + // + // 6. CLEANUP + // - Tap disconnect to reset dapp state + + test('@metamask/connect-evm - Account switching and wallet-side verification', async ({ + currentDeviceDetails, + driver, + }) => { + const platform = currentDeviceDetails.platform; + const useBrowserStackLocal = + process.env.BROWSERSTACK_LOCAL?.toLowerCase() === 'true'; + const DAPP_URL = useBrowserStackLocal + ? `http://bs-local.com:${DAPP_PORT}` + : getDappUrlForBrowser(platform); + + await PlaywrightContextHelpers.withNativeAction(async () => { + await loginToAppPlaywright(); + await ensureAccountGroupsFinishedLoading(currentDeviceDetails); + await launchMobileBrowser(); + await navigateToDapp(DAPP_URL); + }); + await sleep(5000); + + await PlaywrightContextHelpers.withWebAction(async () => { + await BrowserPlaygroundDapp.tapConnectLegacy(); + }, DAPP_URL); + + await PlaywrightContextHelpers.withNativeAction(async () => { + await AndroidScreenHelpers.tapOpenDeeplinkWithMetaMask(); + await unlockIfLockScreenVisible(); + await DappConnectionModal.tapEditAccountsButton(); + await DappConnectionModal.tapAccountButton('Account 3'); + await DappConnectionModal.tapUpdateAccountsButton(); + await DappConnectionModal.tapConnectButton({ + shouldCooldown: true, + timeToCooldown: 2000, + }); + }); - await PlaywrightContextHelpers.withNativeAction(async () => { - // Wait here to make sure UI is visible before attempted interaction await sleep(1000); - // We're only using Android for now - await PlaywrightUtilities.launchApp({ - packageName: APP_PACKAGE_IDS.ANDROID, - }); - await unlockIfLockScreenVisible(); + await switchToMobileBrowser(); + await sleep(1000); - // Change selected account to Account 3 in MetaMask - await WalletView.tapIdenticon(); - await AccountListBottomSheet.tapAccountByName('Account 3'); - }); + await PlaywrightContextHelpers.withWebAction(async () => { + await BrowserPlaygroundDapp.assertConnected(true); + await BrowserPlaygroundDapp.assertChainIdValue('0x1'); + await BrowserPlaygroundDapp.assertActiveAccount(ACCOUNT_1_ADDRESS); + }, DAPP_URL); + + await PlaywrightContextHelpers.withNativeAction(async () => { + // Wait here to make sure UI is visible before attempted interaction + await sleep(1000); + // We're only using Android for now + await PlaywrightUtilities.launchApp({ + packageName: APP_PACKAGE_IDS.ANDROID, + }); + await unlockIfLockScreenVisible(); + + // Change selected account to Account 3 in MetaMask + await WalletView.tapIdenticon(); + await AccountListBottomSheet.tapAccountByName('Account 3'); + }); - await sleep(1000); - await switchToMobileBrowser(); - await sleep(1000); + await sleep(1000); + await switchToMobileBrowser(); + await sleep(1000); - await PlaywrightContextHelpers.withWebAction(async () => { - // Verify account changed to Account 3 - await BrowserPlaygroundDapp.assertActiveAccount(ACCOUNT_3_ADDRESS); - }, DAPP_URL); + await PlaywrightContextHelpers.withWebAction(async () => { + // Verify account changed to Account 3 + await BrowserPlaygroundDapp.assertActiveAccount(ACCOUNT_3_ADDRESS); + }, DAPP_URL); - await PlaywrightContextHelpers.withNativeAction(async () => { - await refreshMobileBrowser(); - }); - await sleep(2000); - - await PlaywrightContextHelpers.withWebAction(async () => { - await BrowserPlaygroundDapp.assertConnected(true); - await BrowserPlaygroundDapp.assertChainIdValue('0x1'); - await BrowserPlaygroundDapp.assertActiveAccount(ACCOUNT_3_ADDRESS); - await BrowserPlaygroundDapp.tapPersonalSign(); - }, DAPP_URL); - - await PlaywrightContextHelpers.withNativeAction(async () => { - await AndroidScreenHelpers.tapOpenDeeplinkWithMetaMask(); - await SignModal.tapCancelButton({ - shouldCooldown: true, - timeToCooldown: 2000, + await PlaywrightContextHelpers.withNativeAction(async () => { + await refreshMobileBrowser(); + }); + await sleep(2000); + + await PlaywrightContextHelpers.withWebAction(async () => { + await BrowserPlaygroundDapp.assertConnected(true); + await BrowserPlaygroundDapp.assertChainIdValue('0x1'); + await BrowserPlaygroundDapp.assertActiveAccount(ACCOUNT_3_ADDRESS); + await BrowserPlaygroundDapp.tapPersonalSign(); + }, DAPP_URL); + + await PlaywrightContextHelpers.withNativeAction(async () => { + await AndroidScreenHelpers.tapOpenDeeplinkWithMetaMask(); + await SignModal.tapCancelButton({ + shouldCooldown: true, + timeToCooldown: 2000, + }); }); - }); - await sleep(1000); - await switchToMobileBrowser(); - await sleep(1000); + await sleep(1000); + await switchToMobileBrowser(); + await sleep(1000); - await PlaywrightContextHelpers.withWebAction(async () => { - await BrowserPlaygroundDapp.assertResponseValue('rejected'); - }, DAPP_URL); + await PlaywrightContextHelpers.withWebAction(async () => { + await BrowserPlaygroundDapp.assertResponseValue('rejected'); + }, DAPP_URL); - // - // Reset dapp state - // + // + // Reset dapp state + // - await PlaywrightContextHelpers.withWebAction(async () => { - await BrowserPlaygroundDapp.tapDisconnect(); - }, DAPP_URL); -}); + await PlaywrightContextHelpers.withWebAction(async () => { + await BrowserPlaygroundDapp.tapDisconnect(); + }, DAPP_URL); + }); +}); // end describe diff --git a/tests/performance/mm-connect/connection-evm-rejection.spec.ts b/tests/performance/mm-connect/connection-evm-rejection.spec.ts index eab3bcae747..22a6014a1df 100644 --- a/tests/performance/mm-connect/connection-evm-rejection.spec.ts +++ b/tests/performance/mm-connect/connection-evm-rejection.spec.ts @@ -1,4 +1,5 @@ import { test } from '../../framework/fixture'; +import { Performance } from '../../tags.performance.js'; import { loginToAppPlaywright } from '../../flows/wallet.flow'; import BrowserPlaygroundDapp from '../../page-objects/MMConnect/BrowserPlaygroundDapp'; @@ -32,197 +33,199 @@ const playgroundServer = new DappServer({ dappVariant: DappVariants.BROWSER_PLAYGROUND, }); -test.beforeAll(async () => { - playgroundServer.setServerPort(DAPP_PORT); - await playgroundServer.start(); - await waitForDappServerReady(DAPP_PORT); - setupAdbReverse(DAPP_PORT); -}); - -test.afterAll(async () => { - cleanupAdbReverse(DAPP_PORT); - await playgroundServer.stop(); -}); - -// Test steps (in order): -// -// 1. LOGIN AND NAVIGATE TO DAPP -// - Login to app, ensure account groups finished loading -// - Launch mobile browser and navigate to the playground dapp -// -// 2. CONNECT VIA LEGACY EVM -// - Tap Connect (Legacy) -// - In MetaMask: tap Connect (cooldown 2s) -// - Assert: connected true, chainId '0x1', active account is Account 1 -// (0x19a7Ad8256ab119655f1D758348501d598fC1C94) -// -// 3. INITIAL REJECTION -// - Tap personal sign -// - In MetaMask: tap Cancel (cooldown 2s) -// - Assert: response value 'rejected' -// -// 4. DISCONNECT AND RECONNECT — FIRST CYCLE -// - Disconnect, assert connected false, tap Connect (Legacy) -// - In MetaMask: tap Connect (cooldown 2s) -// - Assert: connected true, chainId '0x1', active account is Account 1 -// - Tap personal sign; tap Cancel in MetaMask (cooldown 2s) -// - Assert: response value 'rejected' -// -// 5. DISCONNECT AND RECONNECT — SECOND CYCLE -// - Disconnect (no connected false assertion), tap Connect (Legacy) -// - In MetaMask: tap Connect (cooldown 2s) -// - Assert: connected true, chainId '0x1', active account is Account 1 -// - Tap personal sign; tap Cancel in MetaMask (cooldown 2s) -// - Assert: response value 'rejected' -// -// 6. CLEANUP -// - Tap disconnect to reset dapp state - -test('@metamask/connect-evm - Rejection response value verification', async ({ - currentDeviceDetails, - driver, -}) => { - const platform = currentDeviceDetails.platform; - const useBrowserStackLocal = - process.env.BROWSERSTACK_LOCAL?.toLowerCase() === 'true'; - const DAPP_URL = useBrowserStackLocal - ? `http://bs-local.com:${DAPP_PORT}` - : getDappUrlForBrowser(platform); - - await PlaywrightContextHelpers.withNativeAction(async () => { - await loginToAppPlaywright(); - await ensureAccountGroupsFinishedLoading(currentDeviceDetails); - await launchMobileBrowser(); - await navigateToDapp(DAPP_URL); - }); - await sleep(5000); - - await PlaywrightContextHelpers.withWebAction(async () => { - await BrowserPlaygroundDapp.tapConnectLegacy(); - }, DAPP_URL); - - await PlaywrightContextHelpers.withNativeAction(async () => { - await AndroidScreenHelpers.tapOpenDeeplinkWithMetaMask(); - await unlockIfLockScreenVisible(); - await DappConnectionModal.tapConnectButton({ - shouldCooldown: true, - timeToCooldown: 2000, - }); +test.describe(Performance, () => { + test.beforeAll(async () => { + playgroundServer.setServerPort(DAPP_PORT); + await playgroundServer.start(); + await waitForDappServerReady(DAPP_PORT); + setupAdbReverse(DAPP_PORT); }); - await sleep(1000); - await switchToMobileBrowser(); - await sleep(1000); - - await PlaywrightContextHelpers.withWebAction(async () => { - await BrowserPlaygroundDapp.assertConnected(true); - await BrowserPlaygroundDapp.assertChainIdValue('0x1'); - await BrowserPlaygroundDapp.assertActiveAccount(ACCOUNT_1_ADDRESS); - await BrowserPlaygroundDapp.tapPersonalSign(); - }, DAPP_URL); - - await PlaywrightContextHelpers.withNativeAction(async () => { - await AndroidScreenHelpers.tapOpenDeeplinkWithMetaMask(); - await SignModal.tapCancelButton({ - shouldCooldown: true, - timeToCooldown: 2000, - }); + test.afterAll(async () => { + cleanupAdbReverse(DAPP_PORT); + await playgroundServer.stop(); }); - await sleep(1000); - await switchToMobileBrowser(); - await sleep(1000); - - await PlaywrightContextHelpers.withWebAction(async () => { - await BrowserPlaygroundDapp.assertResponseValue('rejected'); - }, DAPP_URL); - - await PlaywrightContextHelpers.withWebAction(async () => { - await BrowserPlaygroundDapp.tapDisconnect(); - await BrowserPlaygroundDapp.assertConnected(false); - await BrowserPlaygroundDapp.tapConnectLegacy(); - }, DAPP_URL); - - await PlaywrightContextHelpers.withNativeAction(async () => { - await AndroidScreenHelpers.tapOpenDeeplinkWithMetaMask(); - await DappConnectionModal.tapConnectButton({ - shouldCooldown: true, - timeToCooldown: 2000, + // Test steps (in order): + // + // 1. LOGIN AND NAVIGATE TO DAPP + // - Login to app, ensure account groups finished loading + // - Launch mobile browser and navigate to the playground dapp + // + // 2. CONNECT VIA LEGACY EVM + // - Tap Connect (Legacy) + // - In MetaMask: tap Connect (cooldown 2s) + // - Assert: connected true, chainId '0x1', active account is Account 1 + // (0x19a7Ad8256ab119655f1D758348501d598fC1C94) + // + // 3. INITIAL REJECTION + // - Tap personal sign + // - In MetaMask: tap Cancel (cooldown 2s) + // - Assert: response value 'rejected' + // + // 4. DISCONNECT AND RECONNECT — FIRST CYCLE + // - Disconnect, assert connected false, tap Connect (Legacy) + // - In MetaMask: tap Connect (cooldown 2s) + // - Assert: connected true, chainId '0x1', active account is Account 1 + // - Tap personal sign; tap Cancel in MetaMask (cooldown 2s) + // - Assert: response value 'rejected' + // + // 5. DISCONNECT AND RECONNECT — SECOND CYCLE + // - Disconnect (no connected false assertion), tap Connect (Legacy) + // - In MetaMask: tap Connect (cooldown 2s) + // - Assert: connected true, chainId '0x1', active account is Account 1 + // - Tap personal sign; tap Cancel in MetaMask (cooldown 2s) + // - Assert: response value 'rejected' + // + // 6. CLEANUP + // - Tap disconnect to reset dapp state + + test('@metamask/connect-evm - Rejection response value verification', async ({ + currentDeviceDetails, + driver, + }) => { + const platform = currentDeviceDetails.platform; + const useBrowserStackLocal = + process.env.BROWSERSTACK_LOCAL?.toLowerCase() === 'true'; + const DAPP_URL = useBrowserStackLocal + ? `http://bs-local.com:${DAPP_PORT}` + : getDappUrlForBrowser(platform); + + await PlaywrightContextHelpers.withNativeAction(async () => { + await loginToAppPlaywright(); + await ensureAccountGroupsFinishedLoading(currentDeviceDetails); + await launchMobileBrowser(); + await navigateToDapp(DAPP_URL); }); - }); - - await sleep(1000); - await switchToMobileBrowser(); - await sleep(1000); - - await PlaywrightContextHelpers.withWebAction(async () => { - await BrowserPlaygroundDapp.assertConnected(true); - await BrowserPlaygroundDapp.assertChainIdValue('0x1'); - await BrowserPlaygroundDapp.assertActiveAccount(ACCOUNT_1_ADDRESS); - await BrowserPlaygroundDapp.tapPersonalSign(); - }, DAPP_URL); - - await PlaywrightContextHelpers.withNativeAction(async () => { - await AndroidScreenHelpers.tapOpenDeeplinkWithMetaMask(); - await SignModal.tapCancelButton({ - shouldCooldown: true, - timeToCooldown: 2000, + await sleep(5000); + + await PlaywrightContextHelpers.withWebAction(async () => { + await BrowserPlaygroundDapp.tapConnectLegacy(); + }, DAPP_URL); + + await PlaywrightContextHelpers.withNativeAction(async () => { + await AndroidScreenHelpers.tapOpenDeeplinkWithMetaMask(); + await unlockIfLockScreenVisible(); + await DappConnectionModal.tapConnectButton({ + shouldCooldown: true, + timeToCooldown: 2000, + }); }); - }); - await sleep(1000); - await switchToMobileBrowser(); - await sleep(1000); + await sleep(1000); + await switchToMobileBrowser(); + await sleep(1000); + + await PlaywrightContextHelpers.withWebAction(async () => { + await BrowserPlaygroundDapp.assertConnected(true); + await BrowserPlaygroundDapp.assertChainIdValue('0x1'); + await BrowserPlaygroundDapp.assertActiveAccount(ACCOUNT_1_ADDRESS); + await BrowserPlaygroundDapp.tapPersonalSign(); + }, DAPP_URL); + + await PlaywrightContextHelpers.withNativeAction(async () => { + await AndroidScreenHelpers.tapOpenDeeplinkWithMetaMask(); + await SignModal.tapCancelButton({ + shouldCooldown: true, + timeToCooldown: 2000, + }); + }); - await PlaywrightContextHelpers.withWebAction(async () => { - await BrowserPlaygroundDapp.assertResponseValue('rejected'); - }, DAPP_URL); + await sleep(1000); + await switchToMobileBrowser(); + await sleep(1000); + + await PlaywrightContextHelpers.withWebAction(async () => { + await BrowserPlaygroundDapp.assertResponseValue('rejected'); + }, DAPP_URL); + + await PlaywrightContextHelpers.withWebAction(async () => { + await BrowserPlaygroundDapp.tapDisconnect(); + await BrowserPlaygroundDapp.assertConnected(false); + await BrowserPlaygroundDapp.tapConnectLegacy(); + }, DAPP_URL); + + await PlaywrightContextHelpers.withNativeAction(async () => { + await AndroidScreenHelpers.tapOpenDeeplinkWithMetaMask(); + await DappConnectionModal.tapConnectButton({ + shouldCooldown: true, + timeToCooldown: 2000, + }); + }); - await PlaywrightContextHelpers.withWebAction(async () => { - await BrowserPlaygroundDapp.tapDisconnect(); - await BrowserPlaygroundDapp.tapConnectLegacy(); - }, DAPP_URL); + await sleep(1000); + await switchToMobileBrowser(); + await sleep(1000); + + await PlaywrightContextHelpers.withWebAction(async () => { + await BrowserPlaygroundDapp.assertConnected(true); + await BrowserPlaygroundDapp.assertChainIdValue('0x1'); + await BrowserPlaygroundDapp.assertActiveAccount(ACCOUNT_1_ADDRESS); + await BrowserPlaygroundDapp.tapPersonalSign(); + }, DAPP_URL); + + await PlaywrightContextHelpers.withNativeAction(async () => { + await AndroidScreenHelpers.tapOpenDeeplinkWithMetaMask(); + await SignModal.tapCancelButton({ + shouldCooldown: true, + timeToCooldown: 2000, + }); + }); - await PlaywrightContextHelpers.withNativeAction(async () => { - await AndroidScreenHelpers.tapOpenDeeplinkWithMetaMask(); - await DappConnectionModal.tapConnectButton({ - shouldCooldown: true, - timeToCooldown: 2000, + await sleep(1000); + await switchToMobileBrowser(); + await sleep(1000); + + await PlaywrightContextHelpers.withWebAction(async () => { + await BrowserPlaygroundDapp.assertResponseValue('rejected'); + }, DAPP_URL); + + await PlaywrightContextHelpers.withWebAction(async () => { + await BrowserPlaygroundDapp.tapDisconnect(); + await BrowserPlaygroundDapp.tapConnectLegacy(); + }, DAPP_URL); + + await PlaywrightContextHelpers.withNativeAction(async () => { + await AndroidScreenHelpers.tapOpenDeeplinkWithMetaMask(); + await DappConnectionModal.tapConnectButton({ + shouldCooldown: true, + timeToCooldown: 2000, + }); }); - }); - await sleep(1000); - await switchToMobileBrowser(); - await sleep(1000); - - await PlaywrightContextHelpers.withWebAction(async () => { - await BrowserPlaygroundDapp.assertConnected(true); - await BrowserPlaygroundDapp.assertChainIdValue('0x1'); - await BrowserPlaygroundDapp.assertActiveAccount(ACCOUNT_1_ADDRESS); - await BrowserPlaygroundDapp.tapPersonalSign(); - }, DAPP_URL); - - await PlaywrightContextHelpers.withNativeAction(async () => { - await AndroidScreenHelpers.tapOpenDeeplinkWithMetaMask(); - await SignModal.tapCancelButton({ - shouldCooldown: true, - timeToCooldown: 2000, + await sleep(1000); + await switchToMobileBrowser(); + await sleep(1000); + + await PlaywrightContextHelpers.withWebAction(async () => { + await BrowserPlaygroundDapp.assertConnected(true); + await BrowserPlaygroundDapp.assertChainIdValue('0x1'); + await BrowserPlaygroundDapp.assertActiveAccount(ACCOUNT_1_ADDRESS); + await BrowserPlaygroundDapp.tapPersonalSign(); + }, DAPP_URL); + + await PlaywrightContextHelpers.withNativeAction(async () => { + await AndroidScreenHelpers.tapOpenDeeplinkWithMetaMask(); + await SignModal.tapCancelButton({ + shouldCooldown: true, + timeToCooldown: 2000, + }); }); - }); - await sleep(1000); - await switchToMobileBrowser(); - await sleep(1000); + await sleep(1000); + await switchToMobileBrowser(); + await sleep(1000); - await PlaywrightContextHelpers.withWebAction(async () => { - await BrowserPlaygroundDapp.assertResponseValue('rejected'); - }, DAPP_URL); + await PlaywrightContextHelpers.withWebAction(async () => { + await BrowserPlaygroundDapp.assertResponseValue('rejected'); + }, DAPP_URL); - // - // Reset dapp state - // + // + // Reset dapp state + // - await PlaywrightContextHelpers.withWebAction(async () => { - await BrowserPlaygroundDapp.tapDisconnect(); - }, DAPP_URL); -}); + await PlaywrightContextHelpers.withWebAction(async () => { + await BrowserPlaygroundDapp.tapDisconnect(); + }, DAPP_URL); + }); +}); // end describe diff --git a/tests/performance/mm-connect/connection-evm-session-timeout.spec.ts b/tests/performance/mm-connect/connection-evm-session-timeout.spec.ts index 52c8d803ecc..b66b9169904 100644 --- a/tests/performance/mm-connect/connection-evm-session-timeout.spec.ts +++ b/tests/performance/mm-connect/connection-evm-session-timeout.spec.ts @@ -1,4 +1,5 @@ import { test } from '../../framework/fixture'; +import { Performance } from '../../tags.performance.js'; import { loginToAppPlaywright } from '../../flows/wallet.flow'; import BrowserPlaygroundDapp from '../../page-objects/MMConnect/BrowserPlaygroundDapp'; @@ -35,158 +36,160 @@ const playgroundServer = new DappServer({ dappVariant: DappVariants.BROWSER_PLAYGROUND, }); -test.beforeAll(async () => { - playgroundServer.setServerPort(DAPP_PORT); - await playgroundServer.start(); - await waitForDappServerReady(DAPP_PORT); - setupAdbReverse(DAPP_PORT); -}); - -test.afterAll(async () => { - cleanupAdbReverse(DAPP_PORT); - await playgroundServer.stop(); -}); - -// Test steps (in order): -// -// 1. LOGIN AND NAVIGATE TO DAPP -// - Login to app, ensure account groups finished loading -// - Launch mobile browser and navigate to the playground dapp -// -// 2. CONNECT VIA LEGACY EVM -// - Tap Connect (Legacy) -// - In MetaMask: tap Connect (cooldown 2s) -// - Assert: connected true, chainId '0x1' -// -// 3. INCOMPLETE SESSION — NOT INTERACTING WITH MODAL -// - Tap disconnect, then tap Connect (Legacy) again -// - In MetaMask: open approval modal but purposely do NOT interact (sleep 2s) -// - Switch to browser (sleep 2s), refresh mobile browser, wait 2s -// - Assert: connected false (incomplete session started timing out) -// - Sleep 10s to let session fully time out -// - Assert: still connected false -// -// 4. RECONNECT AFTER SESSION TIMEOUT -// - Tap Connect (Legacy) -// - In MetaMask: tap Connect (cooldown 2s) -// - Assert: connected true, chainId '0x1' -// -// 5. READ-ONLY METHOD WITH APP TERMINATED -// - Terminate the MetaMask app (PlaywrightGestures.terminateApp) -// - Tap getBalance in the dapp; sleep 10s for RPC response -// - Assert: response value contains 'Balance:' prefix -// (confirms read-only calls go directly to the RPC endpoint, not the wallet) -// -// 6. CLEANUP -// - Tap disconnect to reset dapp state - -// This test is currently being skipped as the mobile app displays a double prompt. -test.skip('@metamask/connect-evm - Incomplete session timeout and read-only methods', async ({ - currentDeviceDetails, - driver, -}) => { - const platform = currentDeviceDetails.platform; - const useBrowserStackLocal = - process.env.BROWSERSTACK_LOCAL?.toLowerCase() === 'true'; - const DAPP_URL = useBrowserStackLocal - ? `http://bs-local.com:${DAPP_PORT}` - : getDappUrlForBrowser(platform); - - await PlaywrightContextHelpers.withNativeAction(async () => { - await loginToAppPlaywright(); - await ensureAccountGroupsFinishedLoading(currentDeviceDetails); - await launchMobileBrowser(); - await navigateToDapp(DAPP_URL); +test.describe(Performance, () => { + test.beforeAll(async () => { + playgroundServer.setServerPort(DAPP_PORT); + await playgroundServer.start(); + await waitForDappServerReady(DAPP_PORT); + setupAdbReverse(DAPP_PORT); }); - await sleep(5000); - - await PlaywrightContextHelpers.withWebAction(async () => { - await BrowserPlaygroundDapp.tapConnectLegacy(); - }, DAPP_URL); - - await PlaywrightContextHelpers.withNativeAction(async () => { - await AndroidScreenHelpers.tapOpenDeeplinkWithMetaMask(); - await unlockIfLockScreenVisible(); - await DappConnectionModal.tapConnectButton({ - shouldCooldown: true, - timeToCooldown: 2000, - }); - }); - - await sleep(1000); - await switchToMobileBrowser(); - await sleep(1000); - - await PlaywrightContextHelpers.withWebAction(async () => { - await BrowserPlaygroundDapp.assertConnected(true); - await BrowserPlaygroundDapp.assertChainIdValue('0x1'); - }, DAPP_URL); - - await PlaywrightContextHelpers.withWebAction(async () => { - await BrowserPlaygroundDapp.tapDisconnect(); - await BrowserPlaygroundDapp.tapConnectLegacy(); - }, DAPP_URL); - await PlaywrightContextHelpers.withNativeAction(async () => { - await AndroidScreenHelpers.tapOpenDeeplinkWithMetaMask(); - // Purposely not interacting with the approval but we still spend some time - // on the app - await sleep(2000); - }); - - await sleep(2000); - await switchToMobileBrowser(); - await sleep(1000); - - await PlaywrightContextHelpers.withNativeAction(async () => { - await refreshMobileBrowser(); + test.afterAll(async () => { + cleanupAdbReverse(DAPP_PORT); + await playgroundServer.stop(); }); - await sleep(2000); - await PlaywrightContextHelpers.withWebAction(async () => { - await BrowserPlaygroundDapp.assertConnected(false); - }, DAPP_URL); + // Test steps (in order): + // + // 1. LOGIN AND NAVIGATE TO DAPP + // - Login to app, ensure account groups finished loading + // - Launch mobile browser and navigate to the playground dapp + // + // 2. CONNECT VIA LEGACY EVM + // - Tap Connect (Legacy) + // - In MetaMask: tap Connect (cooldown 2s) + // - Assert: connected true, chainId '0x1' + // + // 3. INCOMPLETE SESSION — NOT INTERACTING WITH MODAL + // - Tap disconnect, then tap Connect (Legacy) again + // - In MetaMask: open approval modal but purposely do NOT interact (sleep 2s) + // - Switch to browser (sleep 2s), refresh mobile browser, wait 2s + // - Assert: connected false (incomplete session started timing out) + // - Sleep 10s to let session fully time out + // - Assert: still connected false + // + // 4. RECONNECT AFTER SESSION TIMEOUT + // - Tap Connect (Legacy) + // - In MetaMask: tap Connect (cooldown 2s) + // - Assert: connected true, chainId '0x1' + // + // 5. READ-ONLY METHOD WITH APP TERMINATED + // - Terminate the MetaMask app (PlaywrightGestures.terminateApp) + // - Tap getBalance in the dapp; sleep 10s for RPC response + // - Assert: response value contains 'Balance:' prefix + // (confirms read-only calls go directly to the RPC endpoint, not the wallet) + // + // 6. CLEANUP + // - Tap disconnect to reset dapp state + + // This test is currently being skipped as the mobile app displays a double prompt. + test.skip('@metamask/connect-evm - Incomplete session timeout and read-only methods', async ({ + currentDeviceDetails, + driver, + }) => { + const platform = currentDeviceDetails.platform; + const useBrowserStackLocal = + process.env.BROWSERSTACK_LOCAL?.toLowerCase() === 'true'; + const DAPP_URL = useBrowserStackLocal + ? `http://bs-local.com:${DAPP_PORT}` + : getDappUrlForBrowser(platform); + + await PlaywrightContextHelpers.withNativeAction(async () => { + await loginToAppPlaywright(); + await ensureAccountGroupsFinishedLoading(currentDeviceDetails); + await launchMobileBrowser(); + await navigateToDapp(DAPP_URL); + }); + await sleep(5000); + + await PlaywrightContextHelpers.withWebAction(async () => { + await BrowserPlaygroundDapp.tapConnectLegacy(); + }, DAPP_URL); + + await PlaywrightContextHelpers.withNativeAction(async () => { + await AndroidScreenHelpers.tapOpenDeeplinkWithMetaMask(); + await unlockIfLockScreenVisible(); + await DappConnectionModal.tapConnectButton({ + shouldCooldown: true, + timeToCooldown: 2000, + }); + }); - await sleep(10000); + await sleep(1000); + await switchToMobileBrowser(); + await sleep(1000); + + await PlaywrightContextHelpers.withWebAction(async () => { + await BrowserPlaygroundDapp.assertConnected(true); + await BrowserPlaygroundDapp.assertChainIdValue('0x1'); + }, DAPP_URL); + + await PlaywrightContextHelpers.withWebAction(async () => { + await BrowserPlaygroundDapp.tapDisconnect(); + await BrowserPlaygroundDapp.tapConnectLegacy(); + }, DAPP_URL); + + await PlaywrightContextHelpers.withNativeAction(async () => { + await AndroidScreenHelpers.tapOpenDeeplinkWithMetaMask(); + // Purposely not interacting with the approval but we still spend some time + // on the app + await sleep(2000); + }); - await PlaywrightContextHelpers.withWebAction(async () => { - await BrowserPlaygroundDapp.assertConnected(false); - await BrowserPlaygroundDapp.tapConnectLegacy(); - }, DAPP_URL); + await sleep(2000); + await switchToMobileBrowser(); + await sleep(1000); - await PlaywrightContextHelpers.withNativeAction(async () => { - await AndroidScreenHelpers.tapOpenDeeplinkWithMetaMask(); - await DappConnectionModal.tapConnectButton({ - shouldCooldown: true, - timeToCooldown: 2000, + await PlaywrightContextHelpers.withNativeAction(async () => { + await refreshMobileBrowser(); }); - }); - - await sleep(1000); - await switchToMobileBrowser(); - await sleep(1000); + await sleep(2000); - await PlaywrightContextHelpers.withWebAction(async () => { - await BrowserPlaygroundDapp.assertConnected(true); - await BrowserPlaygroundDapp.assertChainIdValue('0x1'); - }, DAPP_URL); + await PlaywrightContextHelpers.withWebAction(async () => { + await BrowserPlaygroundDapp.assertConnected(false); + }, DAPP_URL); - // - // Read-only method should hit rpc endpoint instead of wallet - // - await PlaywrightGestures.terminateApp(currentDeviceDetails); - await PlaywrightContextHelpers.withWebAction(async () => { - await BrowserPlaygroundDapp.tapGetBalance(); await sleep(10000); - // Balance response should contain "Balance:" prefix - await BrowserPlaygroundDapp.assertResponseValue('Balance:'); - }, DAPP_URL); - // - // Reset dapp state - // + await PlaywrightContextHelpers.withWebAction(async () => { + await BrowserPlaygroundDapp.assertConnected(false); + await BrowserPlaygroundDapp.tapConnectLegacy(); + }, DAPP_URL); + + await PlaywrightContextHelpers.withNativeAction(async () => { + await AndroidScreenHelpers.tapOpenDeeplinkWithMetaMask(); + await DappConnectionModal.tapConnectButton({ + shouldCooldown: true, + timeToCooldown: 2000, + }); + }); - await PlaywrightContextHelpers.withWebAction(async () => { - await BrowserPlaygroundDapp.tapDisconnect(); - }, DAPP_URL); -}); + await sleep(1000); + await switchToMobileBrowser(); + await sleep(1000); + + await PlaywrightContextHelpers.withWebAction(async () => { + await BrowserPlaygroundDapp.assertConnected(true); + await BrowserPlaygroundDapp.assertChainIdValue('0x1'); + }, DAPP_URL); + + // + // Read-only method should hit rpc endpoint instead of wallet + // + await PlaywrightGestures.terminateApp(currentDeviceDetails); + await PlaywrightContextHelpers.withWebAction(async () => { + await BrowserPlaygroundDapp.tapGetBalance(); + await sleep(10000); + // Balance response should contain "Balance:" prefix + await BrowserPlaygroundDapp.assertResponseValue('Balance:'); + }, DAPP_URL); + + // + // Reset dapp state + // + + await PlaywrightContextHelpers.withWebAction(async () => { + await BrowserPlaygroundDapp.tapDisconnect(); + }, DAPP_URL); + }); +}); // end describe diff --git a/tests/performance/mm-connect/connection-evm-sign.spec.ts b/tests/performance/mm-connect/connection-evm-sign.spec.ts index 2f5a098e558..81d98dd8c23 100644 --- a/tests/performance/mm-connect/connection-evm-sign.spec.ts +++ b/tests/performance/mm-connect/connection-evm-sign.spec.ts @@ -1,4 +1,5 @@ import { test } from '../../framework/fixture'; +import { Performance } from '../../tags.performance.js'; import { loginToAppPlaywright } from '../../flows/wallet.flow'; import BrowserPlaygroundDapp from '../../page-objects/MMConnect/BrowserPlaygroundDapp'; @@ -33,197 +34,199 @@ const playgroundServer = new DappServer({ dappVariant: DappVariants.BROWSER_PLAYGROUND, }); -test.beforeAll(async () => { - playgroundServer.setServerPort(DAPP_PORT); - await playgroundServer.start(); - await waitForDappServerReady(DAPP_PORT); - setupAdbReverse(DAPP_PORT); -}); - -test.afterAll(async () => { - cleanupAdbReverse(DAPP_PORT); - await playgroundServer.stop(); -}); +test.describe(Performance, () => { + test.beforeAll(async () => { + playgroundServer.setServerPort(DAPP_PORT); + await playgroundServer.start(); + await waitForDappServerReady(DAPP_PORT); + setupAdbReverse(DAPP_PORT); + }); -// Test steps (in order): -// -// 1. LOGIN AND NAVIGATE TO DAPP -// - Login to app, ensure account groups finished loading -// - Launch mobile browser and navigate to the playground dapp -// -// 2. CONNECT VIA LEGACY EVM (WITH ACCOUNT 3) -// - Tap Connect (Legacy) -// - In MetaMask: tap Edit Accounts, add Account 3, tap Update, tap Connect (cooldown 2s) -// - Assert: connected true, chainId '0x1', active account is Account 1 -// (0x19a7Ad8256ab119655f1D758348501d598fC1C94) -// -// 3. SIGN MESSAGE ON ETHEREUM — CONFIRM -// - Tap personal sign -// - In MetaMask: tap Confirm (cooldown 2s) -// - Assert: response value matches Account 1 signature hash -// (0x361c13288b4ab02d50974efddf9e4e7ca651b81c298b614be908c4754abb1dd8 -// 328224645a1a8d0fab561c4b855c7bdcebea15db5ae8d1778a1ea791dbd05c2a1b) -// -// 4. SEND TRANSACTION ON ETHEREUM — CANCEL -// - Tap send transaction -// - In MetaMask: assert network text 'Ethereum', tap Cancel (cooldown 2s) -// - Assert: response value 'denied' -// -// 5. SWITCH TO POLYGON AND SEND TRANSACTION — CANCEL -// - Assert response 'denied', tap switch to Polygon -// - In MetaMask: assert network text 'Polygon', tap Confirm chain switch (cooldown 2s) -// - Assert: chainId '0x89' -// - Tap send transaction -// - In MetaMask: assert network text 'Polygon', tap Cancel (cooldown 2s) -// -// 6. SWITCH TO MAINNET AND SEND TRANSACTION — CANCEL -// - Tap switch to Mainnet, assert chainId '0x1' -// - Tap send transaction -// - In MetaMask: assert network text 'Ethereum', tap Cancel (cooldown 2s) -// -// 7. CLEANUP -// - Tap disconnect to reset dapp state - -test('@metamask/connect-evm - Sign and transaction cancel flows', async ({ - currentDeviceDetails, - driver, -}) => { - const platform = currentDeviceDetails.platform; - const useBrowserStackLocal = - process.env.BROWSERSTACK_LOCAL?.toLowerCase() === 'true'; - const DAPP_URL = useBrowserStackLocal - ? `http://bs-local.com:${DAPP_PORT}` - : getDappUrlForBrowser(platform); - - await PlaywrightContextHelpers.withNativeAction(async () => { - await loginToAppPlaywright(); - await ensureAccountGroupsFinishedLoading(currentDeviceDetails); - await launchMobileBrowser(); - await navigateToDapp(DAPP_URL); + test.afterAll(async () => { + cleanupAdbReverse(DAPP_PORT); + await playgroundServer.stop(); }); - await sleep(5000); - - await PlaywrightContextHelpers.withWebAction(async () => { - await BrowserPlaygroundDapp.tapConnectLegacy(); - }, DAPP_URL); - - await PlaywrightContextHelpers.withNativeAction(async () => { - await AndroidScreenHelpers.tapOpenDeeplinkWithMetaMask(); - await unlockIfLockScreenVisible(); - await DappConnectionModal.tapEditAccountsButton(); - await DappConnectionModal.tapAccountButton('Account 3'); - await DappConnectionModal.tapUpdateAccountsButton(); - await DappConnectionModal.tapConnectButton({ - shouldCooldown: true, - timeToCooldown: 2000, + + // Test steps (in order): + // + // 1. LOGIN AND NAVIGATE TO DAPP + // - Login to app, ensure account groups finished loading + // - Launch mobile browser and navigate to the playground dapp + // + // 2. CONNECT VIA LEGACY EVM (WITH ACCOUNT 3) + // - Tap Connect (Legacy) + // - In MetaMask: tap Edit Accounts, add Account 3, tap Update, tap Connect (cooldown 2s) + // - Assert: connected true, chainId '0x1', active account is Account 1 + // (0x19a7Ad8256ab119655f1D758348501d598fC1C94) + // + // 3. SIGN MESSAGE ON ETHEREUM — CONFIRM + // - Tap personal sign + // - In MetaMask: tap Confirm (cooldown 2s) + // - Assert: response value matches Account 1 signature hash + // (0x361c13288b4ab02d50974efddf9e4e7ca651b81c298b614be908c4754abb1dd8 + // 328224645a1a8d0fab561c4b855c7bdcebea15db5ae8d1778a1ea791dbd05c2a1b) + // + // 4. SEND TRANSACTION ON ETHEREUM — CANCEL + // - Tap send transaction + // - In MetaMask: assert network text 'Ethereum', tap Cancel (cooldown 2s) + // - Assert: response value 'denied' + // + // 5. SWITCH TO POLYGON AND SEND TRANSACTION — CANCEL + // - Assert response 'denied', tap switch to Polygon + // - In MetaMask: assert network text 'Polygon', tap Confirm chain switch (cooldown 2s) + // - Assert: chainId '0x89' + // - Tap send transaction + // - In MetaMask: assert network text 'Polygon', tap Cancel (cooldown 2s) + // + // 6. SWITCH TO MAINNET AND SEND TRANSACTION — CANCEL + // - Tap switch to Mainnet, assert chainId '0x1' + // - Tap send transaction + // - In MetaMask: assert network text 'Ethereum', tap Cancel (cooldown 2s) + // + // 7. CLEANUP + // - Tap disconnect to reset dapp state + + test('@metamask/connect-evm - Sign and transaction cancel flows', async ({ + currentDeviceDetails, + driver, + }) => { + const platform = currentDeviceDetails.platform; + const useBrowserStackLocal = + process.env.BROWSERSTACK_LOCAL?.toLowerCase() === 'true'; + const DAPP_URL = useBrowserStackLocal + ? `http://bs-local.com:${DAPP_PORT}` + : getDappUrlForBrowser(platform); + + await PlaywrightContextHelpers.withNativeAction(async () => { + await loginToAppPlaywright(); + await ensureAccountGroupsFinishedLoading(currentDeviceDetails); + await launchMobileBrowser(); + await navigateToDapp(DAPP_URL); + }); + await sleep(5000); + + await PlaywrightContextHelpers.withWebAction(async () => { + await BrowserPlaygroundDapp.tapConnectLegacy(); + }, DAPP_URL); + + await PlaywrightContextHelpers.withNativeAction(async () => { + await AndroidScreenHelpers.tapOpenDeeplinkWithMetaMask(); + await unlockIfLockScreenVisible(); + await DappConnectionModal.tapEditAccountsButton(); + await DappConnectionModal.tapAccountButton('Account 3'); + await DappConnectionModal.tapUpdateAccountsButton(); + await DappConnectionModal.tapConnectButton({ + shouldCooldown: true, + timeToCooldown: 2000, + }); }); - }); - await sleep(1000); - await switchToMobileBrowser(); - await sleep(1000); - - await PlaywrightContextHelpers.withWebAction(async () => { - await BrowserPlaygroundDapp.assertConnected(true); - await BrowserPlaygroundDapp.assertChainIdValue('0x1'); - await BrowserPlaygroundDapp.assertActiveAccount(ACCOUNT_1_ADDRESS); - await BrowserPlaygroundDapp.tapPersonalSign(); - }, DAPP_URL); - - await PlaywrightContextHelpers.withNativeAction(async () => { - await AndroidScreenHelpers.tapOpenDeeplinkWithMetaMask(); - await SignModal.tapConfirmButton({ - shouldCooldown: true, - timeToCooldown: 2000, + await sleep(1000); + await switchToMobileBrowser(); + await sleep(1000); + + await PlaywrightContextHelpers.withWebAction(async () => { + await BrowserPlaygroundDapp.assertConnected(true); + await BrowserPlaygroundDapp.assertChainIdValue('0x1'); + await BrowserPlaygroundDapp.assertActiveAccount(ACCOUNT_1_ADDRESS); + await BrowserPlaygroundDapp.tapPersonalSign(); + }, DAPP_URL); + + await PlaywrightContextHelpers.withNativeAction(async () => { + await AndroidScreenHelpers.tapOpenDeeplinkWithMetaMask(); + await SignModal.tapConfirmButton({ + shouldCooldown: true, + timeToCooldown: 2000, + }); }); - }); - await sleep(1000); - await switchToMobileBrowser(); - await sleep(1000); - - await PlaywrightContextHelpers.withWebAction(async () => { - await BrowserPlaygroundDapp.assertResponseValue( - // Account 1 signed the message - '0x361c13288b4ab02d50974efddf9e4e7ca651b81c298b614be908c4754abb1dd8328224645a1a8d0fab561c4b855c7bdcebea15db5ae8d1778a1ea791dbd05c2a1b', - ); - await BrowserPlaygroundDapp.tapSendTransaction(); - }, DAPP_URL); - - await PlaywrightContextHelpers.withNativeAction(async () => { - await AndroidScreenHelpers.tapOpenDeeplinkWithMetaMask(); - await SignModal.assertNetworkText('Ethereum'); - await SignModal.tapCancelButton({ - shouldCooldown: true, - timeToCooldown: 2000, + await sleep(1000); + await switchToMobileBrowser(); + await sleep(1000); + + await PlaywrightContextHelpers.withWebAction(async () => { + await BrowserPlaygroundDapp.assertResponseValue( + // Account 1 signed the message + '0x361c13288b4ab02d50974efddf9e4e7ca651b81c298b614be908c4754abb1dd8328224645a1a8d0fab561c4b855c7bdcebea15db5ae8d1778a1ea791dbd05c2a1b', + ); + await BrowserPlaygroundDapp.tapSendTransaction(); + }, DAPP_URL); + + await PlaywrightContextHelpers.withNativeAction(async () => { + await AndroidScreenHelpers.tapOpenDeeplinkWithMetaMask(); + await SignModal.assertNetworkText('Ethereum'); + await SignModal.tapCancelButton({ + shouldCooldown: true, + timeToCooldown: 2000, + }); }); - }); - await sleep(1000); - await switchToMobileBrowser(); - await sleep(1000); - - await PlaywrightContextHelpers.withWebAction(async () => { - // Note: Error message may differ slightly in browser playground - await BrowserPlaygroundDapp.assertResponseValue('denied'); - await BrowserPlaygroundDapp.tapSwitchToPolygon(); - }, DAPP_URL); - - await PlaywrightContextHelpers.withNativeAction(async () => { - await AndroidScreenHelpers.tapOpenDeeplinkWithMetaMask(); - await SwitchChainModal.assertNetworkText('Polygon'); - await SwitchChainModal.tapConnectButton({ - shouldCooldown: true, - timeToCooldown: 2000, + await sleep(1000); + await switchToMobileBrowser(); + await sleep(1000); + + await PlaywrightContextHelpers.withWebAction(async () => { + // Note: Error message may differ slightly in browser playground + await BrowserPlaygroundDapp.assertResponseValue('denied'); + await BrowserPlaygroundDapp.tapSwitchToPolygon(); + }, DAPP_URL); + + await PlaywrightContextHelpers.withNativeAction(async () => { + await AndroidScreenHelpers.tapOpenDeeplinkWithMetaMask(); + await SwitchChainModal.assertNetworkText('Polygon'); + await SwitchChainModal.tapConnectButton({ + shouldCooldown: true, + timeToCooldown: 2000, + }); }); - }); - await sleep(1000); - await switchToMobileBrowser(); - await sleep(1000); - - await PlaywrightContextHelpers.withWebAction(async () => { - await BrowserPlaygroundDapp.assertChainIdValue('0x89'); - await BrowserPlaygroundDapp.tapSendTransaction(); - }, DAPP_URL); - - await PlaywrightContextHelpers.withNativeAction(async () => { - await AndroidScreenHelpers.tapOpenDeeplinkWithMetaMask(); - await SignModal.assertNetworkText('Polygon'); - await SignModal.tapCancelButton({ - shouldCooldown: true, - timeToCooldown: 2000, + await sleep(1000); + await switchToMobileBrowser(); + await sleep(1000); + + await PlaywrightContextHelpers.withWebAction(async () => { + await BrowserPlaygroundDapp.assertChainIdValue('0x89'); + await BrowserPlaygroundDapp.tapSendTransaction(); + }, DAPP_URL); + + await PlaywrightContextHelpers.withNativeAction(async () => { + await AndroidScreenHelpers.tapOpenDeeplinkWithMetaMask(); + await SignModal.assertNetworkText('Polygon'); + await SignModal.tapCancelButton({ + shouldCooldown: true, + timeToCooldown: 2000, + }); }); - }); - await sleep(1000); - await switchToMobileBrowser(); - await sleep(1000); - - await PlaywrightContextHelpers.withWebAction(async () => { - await BrowserPlaygroundDapp.tapSwitchToMainnet(); - await BrowserPlaygroundDapp.assertChainIdValue('0x1'); - await BrowserPlaygroundDapp.tapSendTransaction(); - }, DAPP_URL); - - await PlaywrightContextHelpers.withNativeAction(async () => { - await AndroidScreenHelpers.tapOpenDeeplinkWithMetaMask(); - await SignModal.assertNetworkText('Ethereum'); - await SignModal.tapCancelButton({ - shouldCooldown: true, - timeToCooldown: 2000, + await sleep(1000); + await switchToMobileBrowser(); + await sleep(1000); + + await PlaywrightContextHelpers.withWebAction(async () => { + await BrowserPlaygroundDapp.tapSwitchToMainnet(); + await BrowserPlaygroundDapp.assertChainIdValue('0x1'); + await BrowserPlaygroundDapp.tapSendTransaction(); + }, DAPP_URL); + + await PlaywrightContextHelpers.withNativeAction(async () => { + await AndroidScreenHelpers.tapOpenDeeplinkWithMetaMask(); + await SignModal.assertNetworkText('Ethereum'); + await SignModal.tapCancelButton({ + shouldCooldown: true, + timeToCooldown: 2000, + }); }); - }); - await sleep(1000); - await switchToMobileBrowser(); - await sleep(1000); + await sleep(1000); + await switchToMobileBrowser(); + await sleep(1000); - // - // Reset dapp state - // + // + // Reset dapp state + // - await PlaywrightContextHelpers.withWebAction(async () => { - await BrowserPlaygroundDapp.tapDisconnect(); - }, DAPP_URL); -}); + await PlaywrightContextHelpers.withWebAction(async () => { + await BrowserPlaygroundDapp.tapDisconnect(); + }, DAPP_URL); + }); +}); // end describe diff --git a/tests/performance/mm-connect/connection-multichain.spec.ts b/tests/performance/mm-connect/connection-multichain.spec.ts index cc3971c759a..86869e11af0 100644 --- a/tests/performance/mm-connect/connection-multichain.spec.ts +++ b/tests/performance/mm-connect/connection-multichain.spec.ts @@ -1,4 +1,5 @@ import { test } from '../../framework/fixture'; +import { Performance } from '../../tags.performance.js'; import TimerHelper from '../../framework/TimerHelper'; import { loginToAppPlaywright } from '../../flows/wallet.flow'; import BrowserPlaygroundDapp from '../../page-objects/MMConnect/BrowserPlaygroundDapp'; @@ -35,76 +36,80 @@ const playgroundServer = new DappServer({ dappVariant: DappVariants.BROWSER_PLAYGROUND, }); -// Start local playground server before all tests -test.beforeAll(async () => { - playgroundServer.setServerPort(DAPP_PORT); - await playgroundServer.start(); - await waitForDappServerReady(DAPP_PORT); - setupAdbReverse(DAPP_PORT); -}); +test.describe(Performance, () => { + // Start local playground server before all tests + test.beforeAll(async () => { + playgroundServer.setServerPort(DAPP_PORT); + await playgroundServer.start(); + await waitForDappServerReady(DAPP_PORT); + setupAdbReverse(DAPP_PORT); + }); -// Stop local playground server after all tests -test.afterAll(async () => { - cleanupAdbReverse(DAPP_PORT); - await playgroundServer.stop(); -}); + // Stop local playground server after all tests + test.afterAll(async () => { + cleanupAdbReverse(DAPP_PORT); + await playgroundServer.stop(); + }); -test('@metamask/connect-multichain - Connect via Multichain API to Local Browser Playground', async ({ - currentDeviceDetails, - driver, -}) => { - const useBrowserStackLocal = - process.env.BROWSERSTACK_LOCAL?.toLowerCase() === 'true'; - const DAPP_URL = useBrowserStackLocal - ? `http://bs-local.com:${DAPP_PORT}` - : getDappUrlForBrowser(currentDeviceDetails.platform); + test('@metamask/connect-multichain - Connect via Multichain API to Local Browser Playground', async ({ + currentDeviceDetails, + driver, + }) => { + const useBrowserStackLocal = + process.env.BROWSERSTACK_LOCAL?.toLowerCase() === 'true'; + const DAPP_URL = useBrowserStackLocal + ? `http://bs-local.com:${DAPP_PORT}` + : getDappUrlForBrowser(currentDeviceDetails.platform); - // - // Login and navigate to dapp - // + // + // Login and navigate to dapp + // - await PlaywrightContextHelpers.withNativeAction(async () => { - await loginToAppPlaywright(); - await launchMobileBrowser(); - await navigateToDapp(DAPP_URL); - }); + await PlaywrightContextHelpers.withNativeAction(async () => { + await loginToAppPlaywright(); + await launchMobileBrowser(); + await navigateToDapp(DAPP_URL); + }); - // - // Connect via Multichain API - // - await PlaywrightContextHelpers.withWebAction(async () => { - await BrowserPlaygroundDapp.waitForConnectButtonVisible(15000); - await BrowserPlaygroundDapp.tapConnect(); - }, DAPP_URL); + // + // Connect via Multichain API + // + await PlaywrightContextHelpers.withWebAction(async () => { + await BrowserPlaygroundDapp.waitForConnectButtonVisible(15000); + await BrowserPlaygroundDapp.tapConnect(); + }, DAPP_URL); - // Handle connection approval in MetaMask - await PlaywrightContextHelpers.withNativeAction(async () => { - await AndroidScreenHelpers.tapOpenDeeplinkWithMetaMask(); - await unlockIfLockScreenVisible(); - await DappConnectionModal.tapConnectButton(); - }); + // Handle connection approval in MetaMask + await PlaywrightContextHelpers.withNativeAction(async () => { + await AndroidScreenHelpers.tapOpenDeeplinkWithMetaMask(); + await unlockIfLockScreenVisible(); + await DappConnectionModal.tapConnectButton(); + }); - // Switch back to browser - await switchToMobileBrowser(); - await sleep(500); + // Switch back to browser + await switchToMobileBrowser(); + await sleep(500); - // - // Verify connection - // + // + // Verify connection + // - await PlaywrightContextHelpers.withWebAction(async () => { - await BrowserPlaygroundDapp.assertMultichainConnected(true); - await PlaywrightGestures.scrollIntoView( - await asPlaywrightElement(BrowserPlaygroundDapp.getScopeCard('eip155:1')), - ); - await BrowserPlaygroundDapp.assertScopeCardVisible('eip155:1'); - }, DAPP_URL); + await PlaywrightContextHelpers.withWebAction(async () => { + await BrowserPlaygroundDapp.assertMultichainConnected(true); + await PlaywrightGestures.scrollIntoView( + await asPlaywrightElement( + BrowserPlaygroundDapp.getScopeCard('eip155:1'), + ), + ); + await BrowserPlaygroundDapp.assertScopeCardVisible('eip155:1'); + }, DAPP_URL); - // - // Cleanup - disconnect - // + // + // Cleanup - disconnect + // - await PlaywrightContextHelpers.withWebAction(async () => { - await BrowserPlaygroundDapp.tapDisconnect(); - }, DAPP_URL); -}); + await PlaywrightContextHelpers.withWebAction(async () => { + await BrowserPlaygroundDapp.tapDisconnect(); + }, DAPP_URL); + }); +}); // end describe diff --git a/tests/performance/mm-connect/connection-multiclient-resilience.spec.ts b/tests/performance/mm-connect/connection-multiclient-resilience.spec.ts index 502b8e73f21..53ef06eafd5 100644 --- a/tests/performance/mm-connect/connection-multiclient-resilience.spec.ts +++ b/tests/performance/mm-connect/connection-multiclient-resilience.spec.ts @@ -1,4 +1,5 @@ import { test } from '../../framework/fixture'; +import { Performance } from '../../tags.performance.js'; import { loginToAppPlaywright } from '../../flows/wallet.flow'; import BrowserPlaygroundDapp from '../../page-objects/MMConnect/BrowserPlaygroundDapp'; @@ -47,248 +48,250 @@ const playgroundServer = new DappServer({ dappVariant: DappVariants.BROWSER_PLAYGROUND, }); -// Start local playground server before all tests -test.beforeAll(async () => { - // Set port and start the server directly (bypassing Detox-specific utilities) - playgroundServer.setServerPort(DAPP_PORT); - await playgroundServer.start(); - await waitForDappServerReady(DAPP_PORT); +test.describe(Performance, () => { + // Start local playground server before all tests + test.beforeAll(async () => { + // Set port and start the server directly (bypassing Detox-specific utilities) + playgroundServer.setServerPort(DAPP_PORT); + await playgroundServer.start(); + await waitForDappServerReady(DAPP_PORT); - // Set up adb reverse for Android emulator access - setupAdbReverse(DAPP_PORT); -}); - -// Stop local playground server after all tests -test.afterAll(async () => { - cleanupAdbReverse(DAPP_PORT); - await playgroundServer.stop(); -}); + // Set up adb reverse for Android emulator access + setupAdbReverse(DAPP_PORT); + }); -// Test steps (in order): -// -// 1. DISCONNECT SOLANA, VERIFY EVM PERSISTS -// - Tap Solana disconnect -// - Assert: solana scope gone, Solana disconnected -// - Assert: eip155:1 scope still visible, legacy EVM connected, wagmi connected -// - Wagmi personal sign -> confirm -> assert signature starts with 0x -// -// 2. RECONNECT SOLANA, VERIFY EVM PERSISTS -// - Tap Solana connect -> approve in MetaMask -// - Assert: Solana scope visible, Solana connected with account 1 -// - Solana sign message -> confirm -> assert correct signed result -// - Assert: eip155:1 scope still visible, legacy EVM connected, wagmi connected -// - Wagmi personal sign -> confirm -> assert signature starts with 0x -// -// 3. CONCURRENT CONNECT: PENDING APPROVAL + KILL APP RESILIENCE -// - Disconnect both Solana and wagmi, then tap Solana connect (initiates approval) -// - In MetaMask: terminate app without accepting approval, relaunch and log in -// - Tap wagmi connect -> approve in MetaMask -// - Assert: eip155:1 connected, legacy EVM connected, wagmi connected -// - Assert: Solana connected (the pending Solana session from step 7 was fulfilled) -// -// 4. CLEANUP -// - Tap Solana disconnect and legacy EVM disconnect -test('@metamask/connect-multichain (multiple clients) - Disconnect, reconnect, and resilience via Multichain API', async ({ - currentDeviceDetails, - driver, -}) => { - const platform = currentDeviceDetails.platform; - const useBrowserStackLocal = - process.env.BROWSERSTACK_LOCAL?.toLowerCase() === 'true'; - const DAPP_URL = useBrowserStackLocal - ? `http://bs-local.com:${DAPP_PORT}` - : getDappUrlForBrowser(platform); + // Stop local playground server after all tests + test.afterAll(async () => { + cleanupAdbReverse(DAPP_PORT); + await playgroundServer.stop(); + }); + // Test steps (in order): + // + // 1. DISCONNECT SOLANA, VERIFY EVM PERSISTS + // - Tap Solana disconnect + // - Assert: solana scope gone, Solana disconnected + // - Assert: eip155:1 scope still visible, legacy EVM connected, wagmi connected + // - Wagmi personal sign -> confirm -> assert signature starts with 0x // - // Login and navigate to dapp - // (relies on connection state established by the preceding multiclient test) + // 2. RECONNECT SOLANA, VERIFY EVM PERSISTS + // - Tap Solana connect -> approve in MetaMask + // - Assert: Solana scope visible, Solana connected with account 1 + // - Solana sign message -> confirm -> assert correct signed result + // - Assert: eip155:1 scope still visible, legacy EVM connected, wagmi connected + // - Wagmi personal sign -> confirm -> assert signature starts with 0x // + // 3. CONCURRENT CONNECT: PENDING APPROVAL + KILL APP RESILIENCE + // - Disconnect both Solana and wagmi, then tap Solana connect (initiates approval) + // - In MetaMask: terminate app without accepting approval, relaunch and log in + // - Tap wagmi connect -> approve in MetaMask + // - Assert: eip155:1 connected, legacy EVM connected, wagmi connected + // - Assert: Solana connected (the pending Solana session from step 7 was fulfilled) + // + // 4. CLEANUP + // - Tap Solana disconnect and legacy EVM disconnect + test('@metamask/connect-multichain (multiple clients) - Disconnect, reconnect, and resilience via Multichain API', async ({ + currentDeviceDetails, + driver, + }) => { + const platform = currentDeviceDetails.platform; + const useBrowserStackLocal = + process.env.BROWSERSTACK_LOCAL?.toLowerCase() === 'true'; + const DAPP_URL = useBrowserStackLocal + ? `http://bs-local.com:${DAPP_PORT}` + : getDappUrlForBrowser(platform); + + // + // Login and navigate to dapp + // (relies on connection state established by the preceding multiclient test) + // + + await PlaywrightContextHelpers.withNativeAction(async () => { + await loginToAppPlaywright(); + await ensureAccountGroupsFinishedLoading(currentDeviceDetails); + await launchMobileBrowser(); + await navigateToDapp(DAPP_URL); + }); - await PlaywrightContextHelpers.withNativeAction(async () => { - await loginToAppPlaywright(); - await ensureAccountGroupsFinishedLoading(currentDeviceDetails); - await launchMobileBrowser(); - await navigateToDapp(DAPP_URL); - }); + await sleep(1000); - await sleep(1000); - - // Tap the Connect button (multichain API - default scopes) - await PlaywrightContextHelpers.withWebAction(async () => { - // Note: the Solana wallet standard provider itself has an issue where it does not - // listen for wallet_sessionChanged events, so we need to use the Solana's connect button - // as the entrypoint for now. - await BrowserPlaygroundDapp.tapSolanaConnect(); - }, DAPP_URL); - - // Handle connection approval in MetaMask - await PlaywrightContextHelpers.withNativeAction(async () => { - await AndroidScreenHelpers.tapOpenDeeplinkWithMetaMask(); - await unlockIfLockScreenVisible(); - await DappConnectionModal.tapConnectButton(); - }); + // Tap the Connect button (multichain API - default scopes) + await PlaywrightContextHelpers.withWebAction(async () => { + // Note: the Solana wallet standard provider itself has an issue where it does not + // listen for wallet_sessionChanged events, so we need to use the Solana's connect button + // as the entrypoint for now. + await BrowserPlaygroundDapp.tapSolanaConnect(); + }, DAPP_URL); + + // Handle connection approval in MetaMask + await PlaywrightContextHelpers.withNativeAction(async () => { + await AndroidScreenHelpers.tapOpenDeeplinkWithMetaMask(); + await unlockIfLockScreenVisible(); + await DappConnectionModal.tapConnectButton(); + }); - await sleep(1000); - await switchToMobileBrowser(); - await sleep(1000); + await sleep(1000); + await switchToMobileBrowser(); + await sleep(1000); - // - // Step 1: Disconnect Solana, verify EVM persists - // - await PlaywrightContextHelpers.withWebAction(async () => { - // Disconnect Solana - await BrowserPlaygroundDapp.tapSolanaDisconnect(); - - await BrowserPlaygroundDapp.assertScopeCardNotVisible( - SOLANA_MAINNET_CAIP_CHAIN_ID, - ); - await BrowserPlaygroundDapp.assertSolanaConnected(false); - - // Make sure EVM is still connected - await BrowserPlaygroundDapp.assertScopeCardVisible('eip155:1'); - await BrowserPlaygroundDapp.assertConnected(true); - await BrowserPlaygroundDapp.assertWagmiConnected(true); - // Verify wagmi personal sign works when wagmi is connected - await BrowserPlaygroundDapp.typeWagmiSignMessage('Hello MetaMask'); - await PlaywrightGestures.hideKeyboard(); - await BrowserPlaygroundDapp.tapWagmiSignMessage(); - }, DAPP_URL); - - await PlaywrightContextHelpers.withNativeAction(async () => { - await AndroidScreenHelpers.tapOpenDeeplinkWithMetaMask(); - await SignModal.tapConfirmButton(); - }); + // + // Step 1: Disconnect Solana, verify EVM persists + // + await PlaywrightContextHelpers.withWebAction(async () => { + // Disconnect Solana + await BrowserPlaygroundDapp.tapSolanaDisconnect(); + + await BrowserPlaygroundDapp.assertScopeCardNotVisible( + SOLANA_MAINNET_CAIP_CHAIN_ID, + ); + await BrowserPlaygroundDapp.assertSolanaConnected(false); + + // Make sure EVM is still connected + await BrowserPlaygroundDapp.assertScopeCardVisible('eip155:1'); + await BrowserPlaygroundDapp.assertConnected(true); + await BrowserPlaygroundDapp.assertWagmiConnected(true); + // Verify wagmi personal sign works when wagmi is connected + await BrowserPlaygroundDapp.typeWagmiSignMessage('Hello MetaMask'); + await PlaywrightGestures.hideKeyboard(); + await BrowserPlaygroundDapp.tapWagmiSignMessage(); + }, DAPP_URL); + + await PlaywrightContextHelpers.withNativeAction(async () => { + await AndroidScreenHelpers.tapOpenDeeplinkWithMetaMask(); + await SignModal.tapConfirmButton(); + }); - await sleep(1000); - await switchToMobileBrowser(); - await sleep(1000); + await sleep(1000); + await switchToMobileBrowser(); + await sleep(1000); - await PlaywrightContextHelpers.withWebAction(async () => { - await BrowserPlaygroundDapp.assertWagmiSignatureResult('0x'); + await PlaywrightContextHelpers.withWebAction(async () => { + await BrowserPlaygroundDapp.assertWagmiSignatureResult('0x'); - // Reconnect Solana - await BrowserPlaygroundDapp.tapSolanaConnect(); - }, DAPP_URL); + // Reconnect Solana + await BrowserPlaygroundDapp.tapSolanaConnect(); + }, DAPP_URL); - // Reconnecting Solana takes a bit of time to trigger the deeplink, so we need to wait for it to complete - await sleep(3500); + // Reconnecting Solana takes a bit of time to trigger the deeplink, so we need to wait for it to complete + await sleep(3500); - await PlaywrightContextHelpers.withNativeAction(async () => { - await AndroidScreenHelpers.tapOpenDeeplinkWithMetaMask(); - // Reconnecting Solana takes a bit of time, so we need to wait for it to complete - await DappConnectionModal.tapConnectButton({ - shouldCooldown: true, - timeToCooldown: 4000, + await PlaywrightContextHelpers.withNativeAction(async () => { + await AndroidScreenHelpers.tapOpenDeeplinkWithMetaMask(); + // Reconnecting Solana takes a bit of time, so we need to wait for it to complete + await DappConnectionModal.tapConnectButton({ + shouldCooldown: true, + timeToCooldown: 4000, + }); }); - }); - await sleep(1000); - await switchToMobileBrowser(); - await sleep(1000); - - await PlaywrightContextHelpers.withWebAction(async () => { - await BrowserPlaygroundDapp.assertScopeCardVisible( - SOLANA_MAINNET_CAIP_CHAIN_ID, - ); - await BrowserPlaygroundDapp.assertSolanaConnected(true); - await BrowserPlaygroundDapp.assertSolanaActiveAccount( - ACCOUNT_1_SOLANA_ADDRESS, - ); - // Verify solana sign works when solana is connected - await PlaywrightGestures.scrollIntoView( - await asPlaywrightElement(BrowserPlaygroundDapp.solanaCard), - { scrollParams: { direction: 'down' } }, - ); - await BrowserPlaygroundDapp.tapSolanaSignMessage(); - }, DAPP_URL); - - await PlaywrightContextHelpers.withNativeAction(async () => { - await AndroidScreenHelpers.tapOpenDeeplinkWithMetaMask(); - await SnapSignModal.tapConfirmButton(); - }); + await sleep(1000); + await switchToMobileBrowser(); + await sleep(1000); - await sleep(1000); - await switchToMobileBrowser(); - await sleep(1000); - - await PlaywrightContextHelpers.withWebAction(async () => { - await BrowserPlaygroundDapp.assertSolanaSignedMessageResult( - ACCOUNT_1_SOLANA_SIGNED_MESSAGE_RESULT, - ); - - // Make sure EVM is still connected - await BrowserPlaygroundDapp.assertScopeCardVisible('eip155:1'); - await BrowserPlaygroundDapp.assertConnected(true); - await BrowserPlaygroundDapp.assertWagmiConnected(true); - // Verify wagmi personal sign works when wagmi is connected - await BrowserPlaygroundDapp.typeWagmiSignMessage('Hello MetaMask'); - await PlaywrightGestures.hideKeyboard(); - await BrowserPlaygroundDapp.tapWagmiSignMessage(); - }, DAPP_URL); - - await PlaywrightContextHelpers.withNativeAction(async () => { - await AndroidScreenHelpers.tapOpenDeeplinkWithMetaMask(); - await SignModal.tapConfirmButton(); - }); + await PlaywrightContextHelpers.withWebAction(async () => { + await BrowserPlaygroundDapp.assertScopeCardVisible( + SOLANA_MAINNET_CAIP_CHAIN_ID, + ); + await BrowserPlaygroundDapp.assertSolanaConnected(true); + await BrowserPlaygroundDapp.assertSolanaActiveAccount( + ACCOUNT_1_SOLANA_ADDRESS, + ); + // Verify solana sign works when solana is connected + await PlaywrightGestures.scrollIntoView( + await asPlaywrightElement(BrowserPlaygroundDapp.solanaCard), + { scrollParams: { direction: 'down' } }, + ); + await BrowserPlaygroundDapp.tapSolanaSignMessage(); + }, DAPP_URL); + + await PlaywrightContextHelpers.withNativeAction(async () => { + await AndroidScreenHelpers.tapOpenDeeplinkWithMetaMask(); + await SnapSignModal.tapConfirmButton(); + }); - await sleep(1000); - await switchToMobileBrowser(); - await sleep(1000); + await sleep(1000); + await switchToMobileBrowser(); + await sleep(1000); - await PlaywrightContextHelpers.withWebAction(async () => { - await BrowserPlaygroundDapp.assertWagmiSignatureResult('0x'); + await PlaywrightContextHelpers.withWebAction(async () => { + await BrowserPlaygroundDapp.assertSolanaSignedMessageResult( + ACCOUNT_1_SOLANA_SIGNED_MESSAGE_RESULT, + ); + + // Make sure EVM is still connected + await BrowserPlaygroundDapp.assertScopeCardVisible('eip155:1'); + await BrowserPlaygroundDapp.assertConnected(true); + await BrowserPlaygroundDapp.assertWagmiConnected(true); + // Verify wagmi personal sign works when wagmi is connected + await BrowserPlaygroundDapp.typeWagmiSignMessage('Hello MetaMask'); + await PlaywrightGestures.hideKeyboard(); + await BrowserPlaygroundDapp.tapWagmiSignMessage(); + }, DAPP_URL); + + await PlaywrightContextHelpers.withNativeAction(async () => { + await AndroidScreenHelpers.tapOpenDeeplinkWithMetaMask(); + await SignModal.tapConfirmButton(); + }); - // Setup for concurrent connect test + await sleep(1000); + await switchToMobileBrowser(); + await sleep(1000); - await BrowserPlaygroundDapp.tapSolanaDisconnect(); - await BrowserPlaygroundDapp.tapWagmiDisconnect(); - await BrowserPlaygroundDapp.tapSolanaConnect(); - }, DAPP_URL); + await PlaywrightContextHelpers.withWebAction(async () => { + await BrowserPlaygroundDapp.assertWagmiSignatureResult('0x'); - await PlaywrightContextHelpers.withNativeAction(async () => { - await AndroidScreenHelpers.tapOpenDeeplinkWithMetaMask(); + // Setup for concurrent connect test - // Purposely terminate the app without accepting the approval - await PlaywrightGestures.terminateApp(currentDeviceDetails); - await PlaywrightGestures.activateApp(currentDeviceDetails); - await loginToAppPlaywright(); - await sleep(1000); - }); + await BrowserPlaygroundDapp.tapSolanaDisconnect(); + await BrowserPlaygroundDapp.tapWagmiDisconnect(); + await BrowserPlaygroundDapp.tapSolanaConnect(); + }, DAPP_URL); - await sleep(1000); - await switchToMobileBrowser(); - await sleep(1000); + await PlaywrightContextHelpers.withNativeAction(async () => { + await AndroidScreenHelpers.tapOpenDeeplinkWithMetaMask(); - await PlaywrightContextHelpers.withWebAction(async () => { - await BrowserPlaygroundDapp.tapConnectWagmi(); - }, DAPP_URL); + // Purposely terminate the app without accepting the approval + await PlaywrightGestures.terminateApp(currentDeviceDetails); + await PlaywrightGestures.activateApp(currentDeviceDetails); + await loginToAppPlaywright(); + await sleep(1000); + }); - await PlaywrightContextHelpers.withNativeAction(async () => { - await AndroidScreenHelpers.tapOpenDeeplinkWithMetaMask(); - await DappConnectionModal.tapConnectButton(); - }); + await sleep(1000); + await switchToMobileBrowser(); + await sleep(1000); - await sleep(1000); - await switchToMobileBrowser(); - await sleep(1000); + await PlaywrightContextHelpers.withWebAction(async () => { + await BrowserPlaygroundDapp.tapConnectWagmi(); + }, DAPP_URL); - await PlaywrightContextHelpers.withWebAction(async () => { - await BrowserPlaygroundDapp.assertScopeCardVisible('eip155:1'); - await BrowserPlaygroundDapp.assertConnected(true); - await BrowserPlaygroundDapp.assertWagmiConnected(true); - // Currently this is only possible if the solana connection attempt (the first one that initiated) was successful. - await BrowserPlaygroundDapp.assertSolanaConnected(true); - }, DAPP_URL); + await PlaywrightContextHelpers.withNativeAction(async () => { + await AndroidScreenHelpers.tapOpenDeeplinkWithMetaMask(); + await DappConnectionModal.tapConnectButton(); + }); - // - // Cleanup - disconnect - // + await sleep(1000); + await switchToMobileBrowser(); + await sleep(1000); - await PlaywrightContextHelpers.withWebAction(async () => { - // Note: the Solana wallet standard provider itself has an issue where it does not - // listen for wallet_sessionChanged events, so we need to use the Solana's disconnect button - // to ensure the solana react hook state is reset correctly. - await BrowserPlaygroundDapp.tapSolanaDisconnect(); - await BrowserPlaygroundDapp.tapDisconnect(); - }, DAPP_URL); -}); + await PlaywrightContextHelpers.withWebAction(async () => { + await BrowserPlaygroundDapp.assertScopeCardVisible('eip155:1'); + await BrowserPlaygroundDapp.assertConnected(true); + await BrowserPlaygroundDapp.assertWagmiConnected(true); + // Currently this is only possible if the solana connection attempt (the first one that initiated) was successful. + await BrowserPlaygroundDapp.assertSolanaConnected(true); + }, DAPP_URL); + + // + // Cleanup - disconnect + // + + await PlaywrightContextHelpers.withWebAction(async () => { + // Note: the Solana wallet standard provider itself has an issue where it does not + // listen for wallet_sessionChanged events, so we need to use the Solana's disconnect button + // to ensure the solana react hook state is reset correctly. + await BrowserPlaygroundDapp.tapSolanaDisconnect(); + await BrowserPlaygroundDapp.tapDisconnect(); + }, DAPP_URL); + }); +}); // end describe diff --git a/tests/performance/mm-connect/connection-multiclient.spec.ts b/tests/performance/mm-connect/connection-multiclient.spec.ts index 626a2e23856..178d712e1b6 100644 --- a/tests/performance/mm-connect/connection-multiclient.spec.ts +++ b/tests/performance/mm-connect/connection-multiclient.spec.ts @@ -1,4 +1,5 @@ import { test } from '../../framework/fixture'; +import { Performance } from '../../tags.performance.js'; import { loginToAppPlaywright } from '../../flows/wallet.flow'; import BrowserPlaygroundDapp from '../../page-objects/MMConnect/BrowserPlaygroundDapp'; @@ -47,273 +48,279 @@ const playgroundServer = new DappServer({ dappVariant: DappVariants.BROWSER_PLAYGROUND, }); -// Start local playground server before all tests -test.beforeAll(async () => { - // Set port and start the server directly (bypassing Detox-specific utilities) - playgroundServer.setServerPort(DAPP_PORT); - await playgroundServer.start(); - await waitForDappServerReady(DAPP_PORT); +test.describe(Performance, () => { + // Start local playground server before all tests + test.beforeAll(async () => { + // Set port and start the server directly (bypassing Detox-specific utilities) + playgroundServer.setServerPort(DAPP_PORT); + await playgroundServer.start(); + await waitForDappServerReady(DAPP_PORT); - // Set up adb reverse for Android emulator access - setupAdbReverse(DAPP_PORT); -}); - -// Stop local playground server after all tests -test.afterAll(async () => { - cleanupAdbReverse(DAPP_PORT); - await playgroundServer.stop(); -}); + // Set up adb reverse for Android emulator access + setupAdbReverse(DAPP_PORT); + }); -// Test steps (in order): -// -// 1. Login and navigate to the local browser playground dapp -// -// 2. INITIAL MULTICHAIN CONNECTION -// - Tap Solana connect (used as multichain entrypoint due to wallet_sessionChanged limitation) -// - Approve connection in MetaMask -// - Assert: multichain connected, eip155:1 scope visible, solana mainnet scope visible -// - Assert: legacy EVM connected (chainId 0x1, account 1), wagmi connected (chainId 1, account 1) -// - Wagmi personal sign -> confirm -> assert signature starts with 0x -// - Assert: Solana connected with account 1 -// - Solana sign message -> confirm via SnapSignModal -> assert correct signed result -// - Legacy EVM personal sign -> confirm -> assert correct signature -// -// 3. DISCONNECT EVM, VERIFY SOLANA PERSISTS -// - Tap wagmi disconnect -// - Assert: multichain still connected, eip155:1 scope gone, solana scope still visible -// - Assert: legacy EVM disconnected, wagmi disconnected, Solana still connected -// -// 4. RECONNECT EVM, VERIFY SOLANA PERSISTS -// - Tap wagmi connect -> approve in MetaMask -// - Assert: eip155:1 scope visible, legacy EVM connected, wagmi connected -// - Wagmi personal sign -> confirm -> assert signature starts with 0x -// - Assert: Solana scope still visible, Solana still connected -// - Solana sign message -> confirm -> assert correct signed result -test('@metamask/connect-multichain (multiple clients) - Connect multiple clients via Multichain API to Local Browser Playground', async ({ - currentDeviceDetails, -}) => { - // Get platform-specific URL - const platform = currentDeviceDetails.platform; - const useBrowserStackLocal = - process.env.BROWSERSTACK_LOCAL?.toLowerCase() === 'true'; - const DAPP_URL = useBrowserStackLocal - ? `http://bs-local.com:${DAPP_PORT}` - : getDappUrlForBrowser(platform); + // Stop local playground server after all tests + test.afterAll(async () => { + cleanupAdbReverse(DAPP_PORT); + await playgroundServer.stop(); + }); + // Test steps (in order): // - // Login and navigate to dapp + // 1. Login and navigate to the local browser playground dapp // - - await PlaywrightContextHelpers.withNativeAction(async () => { - await loginToAppPlaywright(); - await ensureAccountGroupsFinishedLoading(currentDeviceDetails); - await launchMobileBrowser(); - await navigateToDapp(DAPP_URL); - }); - - await sleep(1000); - + // 2. INITIAL MULTICHAIN CONNECTION + // - Tap Solana connect (used as multichain entrypoint due to wallet_sessionChanged limitation) + // - Approve connection in MetaMask + // - Assert: multichain connected, eip155:1 scope visible, solana mainnet scope visible + // - Assert: legacy EVM connected (chainId 0x1, account 1), wagmi connected (chainId 1, account 1) + // - Wagmi personal sign -> confirm -> assert signature starts with 0x + // - Assert: Solana connected with account 1 + // - Solana sign message -> confirm via SnapSignModal -> assert correct signed result + // - Legacy EVM personal sign -> confirm -> assert correct signature // - // Connect via Multichain API + // 3. DISCONNECT EVM, VERIFY SOLANA PERSISTS + // - Tap wagmi disconnect + // - Assert: multichain still connected, eip155:1 scope gone, solana scope still visible + // - Assert: legacy EVM disconnected, wagmi disconnected, Solana still connected // - - // Tap the Connect button (multichain API - default scopes) - await PlaywrightContextHelpers.withWebAction(async () => { - // Note: the Solana wallet standard provider itself has an issue where it does not - // listen for wallet_sessionChanged events, so we need to use the Solana's connect button - // as the entrypoint for now. - await BrowserPlaygroundDapp.tapSolanaConnect(); - }, DAPP_URL); - - // Handle connection approval in MetaMask - await PlaywrightContextHelpers.withNativeAction(async () => { - await AndroidScreenHelpers.tapOpenDeeplinkWithMetaMask(); - await unlockIfLockScreenVisible(); - await DappConnectionModal.tapConnectButton(); - }); - - await sleep(1000); - await switchToMobileBrowser(); - await sleep(1000); - - await PlaywrightContextHelpers.withWebAction(async () => { - await BrowserPlaygroundDapp.assertMultichainConnected(true); - await BrowserPlaygroundDapp.assertScopeCardVisible('eip155:1'); - await BrowserPlaygroundDapp.assertScopeCardVisible( - SOLANA_MAINNET_CAIP_CHAIN_ID, - ); - - await BrowserPlaygroundDapp.assertConnected(true); - await BrowserPlaygroundDapp.assertChainIdValue('0x1'); - await BrowserPlaygroundDapp.assertActiveAccount(ACCOUNT_1_EVM_ADDRESS); - - await BrowserPlaygroundDapp.assertWagmiConnected(true); - await BrowserPlaygroundDapp.assertWagmiChainIdValue('1'); - await BrowserPlaygroundDapp.assertWagmiActiveAccount(ACCOUNT_1_EVM_ADDRESS); - // Verify wagmi personal sign works when wagmi is connected - await BrowserPlaygroundDapp.typeWagmiSignMessage('Hello MetaMask'); - await PlaywrightGestures.hideKeyboard(); - await BrowserPlaygroundDapp.tapWagmiSignMessage(); - }, DAPP_URL); - - await PlaywrightContextHelpers.withNativeAction(async () => { - await AndroidScreenHelpers.tapOpenDeeplinkWithMetaMask(); - await SignModal.tapConfirmButton(); + // 4. RECONNECT EVM, VERIFY SOLANA PERSISTS + // - Tap wagmi connect -> approve in MetaMask + // - Assert: eip155:1 scope visible, legacy EVM connected, wagmi connected + // - Wagmi personal sign -> confirm -> assert signature starts with 0x + // - Assert: Solana scope still visible, Solana still connected + // - Solana sign message -> confirm -> assert correct signed result + test('@metamask/connect-multichain (multiple clients) - Connect multiple clients via Multichain API to Local Browser Playground', async ({ + currentDeviceDetails, + }) => { + // Get platform-specific URL + const platform = currentDeviceDetails.platform; + const useBrowserStackLocal = + process.env.BROWSERSTACK_LOCAL?.toLowerCase() === 'true'; + const DAPP_URL = useBrowserStackLocal + ? `http://bs-local.com:${DAPP_PORT}` + : getDappUrlForBrowser(platform); + + // + // Login and navigate to dapp + // + + await PlaywrightContextHelpers.withNativeAction(async () => { + await loginToAppPlaywright(); + await ensureAccountGroupsFinishedLoading(currentDeviceDetails); + await launchMobileBrowser(); + await navigateToDapp(DAPP_URL); + }); + + await sleep(1000); + + // + // Connect via Multichain API + // + + // Tap the Connect button (multichain API - default scopes) + await PlaywrightContextHelpers.withWebAction(async () => { + // Note: the Solana wallet standard provider itself has an issue where it does not + // listen for wallet_sessionChanged events, so we need to use the Solana's connect button + // as the entrypoint for now. + await BrowserPlaygroundDapp.tapSolanaConnect(); + }, DAPP_URL); + + // Handle connection approval in MetaMask + await PlaywrightContextHelpers.withNativeAction(async () => { + await AndroidScreenHelpers.tapOpenDeeplinkWithMetaMask(); + await unlockIfLockScreenVisible(); + await DappConnectionModal.tapConnectButton(); + }); + + await sleep(1000); + await switchToMobileBrowser(); + await sleep(1000); + + await PlaywrightContextHelpers.withWebAction(async () => { + await BrowserPlaygroundDapp.assertMultichainConnected(true); + await BrowserPlaygroundDapp.assertScopeCardVisible('eip155:1'); + await BrowserPlaygroundDapp.assertScopeCardVisible( + SOLANA_MAINNET_CAIP_CHAIN_ID, + ); + + await BrowserPlaygroundDapp.assertConnected(true); + await BrowserPlaygroundDapp.assertChainIdValue('0x1'); + await BrowserPlaygroundDapp.assertActiveAccount(ACCOUNT_1_EVM_ADDRESS); + + await BrowserPlaygroundDapp.assertWagmiConnected(true); + await BrowserPlaygroundDapp.assertWagmiChainIdValue('1'); + await BrowserPlaygroundDapp.assertWagmiActiveAccount( + ACCOUNT_1_EVM_ADDRESS, + ); + // Verify wagmi personal sign works when wagmi is connected + await BrowserPlaygroundDapp.typeWagmiSignMessage('Hello MetaMask'); + await PlaywrightGestures.hideKeyboard(); + await BrowserPlaygroundDapp.tapWagmiSignMessage(); + }, DAPP_URL); + + await PlaywrightContextHelpers.withNativeAction(async () => { + await AndroidScreenHelpers.tapOpenDeeplinkWithMetaMask(); + await SignModal.tapConfirmButton(); + }); + + await sleep(1000); + await switchToMobileBrowser(); + await sleep(1000); + + await PlaywrightContextHelpers.withWebAction(async () => { + await BrowserPlaygroundDapp.assertWagmiSignatureResult('0x'); + + await BrowserPlaygroundDapp.assertSolanaConnected(true); + await BrowserPlaygroundDapp.assertSolanaActiveAccount( + ACCOUNT_1_SOLANA_ADDRESS, + ); + // Verify solana sign works when solana is connected + await PlaywrightGestures.scrollIntoView( + await asPlaywrightElement(BrowserPlaygroundDapp.solanaCard), + ); + await BrowserPlaygroundDapp.tapSolanaSignMessage(); + }, DAPP_URL); + + await PlaywrightContextHelpers.withNativeAction(async () => { + await AndroidScreenHelpers.tapOpenDeeplinkWithMetaMask(); + await SnapSignModal.tapConfirmButton(); + }); + + await sleep(1000); + await switchToMobileBrowser(); + await sleep(1000); + + await PlaywrightContextHelpers.withWebAction(async () => { + await BrowserPlaygroundDapp.assertSolanaSignedMessageResult( + ACCOUNT_1_SOLANA_SIGNED_MESSAGE_RESULT, + ); + + await PlaywrightGestures.scrollIntoView( + await asPlaywrightElement(BrowserPlaygroundDapp.legacyEvmCard), + { scrollParams: { direction: 'down' } }, + ); + // Test EVM sign (legacy personal sign) when EVM is connected + await BrowserPlaygroundDapp.tapPersonalSign(); + }, DAPP_URL); + + await PlaywrightContextHelpers.withNativeAction(async () => { + await AndroidScreenHelpers.tapOpenDeeplinkWithMetaMask(); + await SignModal.tapConfirmButton(); + }); + + await sleep(1000); + await switchToMobileBrowser(); + await sleep(1000); + + await PlaywrightContextHelpers.withWebAction(async () => { + await BrowserPlaygroundDapp.assertResponseValue( + '0x361c13288b4ab02d50974efddf9e4e7ca651b81c298b614be908c4754abb1dd8328224645a1a8d0fab561c4b855c7bdcebea15db5ae8d1778a1ea791dbd05c2a1b', + ); + + // Disconnect EVM + await PlaywrightGestures.scrollIntoView( + await asPlaywrightElement(BrowserPlaygroundDapp.wagmiDisconnectButton), + ); + await BrowserPlaygroundDapp.tapWagmiDisconnect(); + + await PlaywrightGestures.scrollIntoView( + await asPlaywrightElement(BrowserPlaygroundDapp.connectedScopesSection), + { scrollParams: { direction: 'down' } }, + ); + await BrowserPlaygroundDapp.assertMultichainConnected(true); + await BrowserPlaygroundDapp.assertScopeCardNotVisible('eip155:1'); + await BrowserPlaygroundDapp.assertScopeCardVisible( + SOLANA_MAINNET_CAIP_CHAIN_ID, + ); + + await BrowserPlaygroundDapp.assertConnected(false); + await BrowserPlaygroundDapp.assertWagmiConnected(false); + await BrowserPlaygroundDapp.assertSolanaConnected(true); + + // Reconnect EVM + await PlaywrightGestures.scrollIntoView( + await asPlaywrightElement(BrowserPlaygroundDapp.connectWagmiButton), + { scrollParams: { direction: 'down' } }, + ); + await BrowserPlaygroundDapp.tapConnectWagmi(); + }, DAPP_URL); + + await PlaywrightContextHelpers.withNativeAction(async () => { + await AndroidScreenHelpers.tapOpenDeeplinkWithMetaMask(); + await DappConnectionModal.tapConnectButton({ shouldCooldown: true }); + }); + + await sleep(1000); + await switchToMobileBrowser(); + await sleep(1000); + + await PlaywrightContextHelpers.withWebAction(async () => { + await PlaywrightGestures.scrollIntoView( + await asPlaywrightElement(BrowserPlaygroundDapp.wagmiCard), + { scrollParams: { direction: 'up' } }, + ); + + await BrowserPlaygroundDapp.assertScopeCardVisible('eip155:1'); + + await BrowserPlaygroundDapp.assertConnected(true); + await BrowserPlaygroundDapp.assertChainIdValue('0x1'); + await BrowserPlaygroundDapp.assertActiveAccount(ACCOUNT_1_EVM_ADDRESS); + + await BrowserPlaygroundDapp.assertWagmiConnected(true); + await BrowserPlaygroundDapp.assertWagmiChainIdValue('1'); + await BrowserPlaygroundDapp.assertWagmiActiveAccount( + ACCOUNT_1_EVM_ADDRESS, + ); + // Verify wagmi personal sign works when wagmi is connected + await BrowserPlaygroundDapp.typeWagmiSignMessage('Hello MetaMask'); + await PlaywrightGestures.hideKeyboard(); + await BrowserPlaygroundDapp.tapWagmiSignMessage(); + }, DAPP_URL); + + await PlaywrightContextHelpers.withNativeAction(async () => { + await AndroidScreenHelpers.tapOpenDeeplinkWithMetaMask(); + await SignModal.tapConfirmButton(); + }); + + await sleep(1000); + await switchToMobileBrowser(); + await sleep(1000); + + await PlaywrightContextHelpers.withWebAction(async () => { + await BrowserPlaygroundDapp.assertWagmiSignatureResult('0x'); + + // Make sure solana is still connected + await BrowserPlaygroundDapp.assertScopeCardVisible( + SOLANA_MAINNET_CAIP_CHAIN_ID, + ); + await BrowserPlaygroundDapp.assertSolanaConnected(true); + await BrowserPlaygroundDapp.assertSolanaActiveAccount( + ACCOUNT_1_SOLANA_ADDRESS, + ); + // Verify solana sign works when solana is connected + await BrowserPlaygroundDapp.tapSolanaSignMessage(); + }, DAPP_URL); + + await PlaywrightContextHelpers.withNativeAction(async () => { + await AndroidScreenHelpers.tapOpenDeeplinkWithMetaMask(); + await SnapSignModal.tapConfirmButton(); + }); + + await sleep(1000); + await switchToMobileBrowser(); + await sleep(1000); + + await PlaywrightContextHelpers.withWebAction(async () => { + await PlaywrightGestures.scrollIntoView( + await asPlaywrightElement( + BrowserPlaygroundDapp.solanaSignedMessageResult, + ), + ); + await BrowserPlaygroundDapp.assertSolanaSignedMessageResult( + ACCOUNT_1_SOLANA_SIGNED_MESSAGE_RESULT, + ); + }, DAPP_URL); }); - - await sleep(1000); - await switchToMobileBrowser(); - await sleep(1000); - - await PlaywrightContextHelpers.withWebAction(async () => { - await BrowserPlaygroundDapp.assertWagmiSignatureResult('0x'); - - await BrowserPlaygroundDapp.assertSolanaConnected(true); - await BrowserPlaygroundDapp.assertSolanaActiveAccount( - ACCOUNT_1_SOLANA_ADDRESS, - ); - // Verify solana sign works when solana is connected - await PlaywrightGestures.scrollIntoView( - await asPlaywrightElement(BrowserPlaygroundDapp.solanaCard), - ); - await BrowserPlaygroundDapp.tapSolanaSignMessage(); - }, DAPP_URL); - - await PlaywrightContextHelpers.withNativeAction(async () => { - await AndroidScreenHelpers.tapOpenDeeplinkWithMetaMask(); - await SnapSignModal.tapConfirmButton(); - }); - - await sleep(1000); - await switchToMobileBrowser(); - await sleep(1000); - - await PlaywrightContextHelpers.withWebAction(async () => { - await BrowserPlaygroundDapp.assertSolanaSignedMessageResult( - ACCOUNT_1_SOLANA_SIGNED_MESSAGE_RESULT, - ); - - await PlaywrightGestures.scrollIntoView( - await asPlaywrightElement(BrowserPlaygroundDapp.legacyEvmCard), - { scrollParams: { direction: 'down' } }, - ); - // Test EVM sign (legacy personal sign) when EVM is connected - await BrowserPlaygroundDapp.tapPersonalSign(); - }, DAPP_URL); - - await PlaywrightContextHelpers.withNativeAction(async () => { - await AndroidScreenHelpers.tapOpenDeeplinkWithMetaMask(); - await SignModal.tapConfirmButton(); - }); - - await sleep(1000); - await switchToMobileBrowser(); - await sleep(1000); - - await PlaywrightContextHelpers.withWebAction(async () => { - await BrowserPlaygroundDapp.assertResponseValue( - '0x361c13288b4ab02d50974efddf9e4e7ca651b81c298b614be908c4754abb1dd8328224645a1a8d0fab561c4b855c7bdcebea15db5ae8d1778a1ea791dbd05c2a1b', - ); - - // Disconnect EVM - await PlaywrightGestures.scrollIntoView( - await asPlaywrightElement(BrowserPlaygroundDapp.wagmiDisconnectButton), - ); - await BrowserPlaygroundDapp.tapWagmiDisconnect(); - - await PlaywrightGestures.scrollIntoView( - await asPlaywrightElement(BrowserPlaygroundDapp.connectedScopesSection), - { scrollParams: { direction: 'down' } }, - ); - await BrowserPlaygroundDapp.assertMultichainConnected(true); - await BrowserPlaygroundDapp.assertScopeCardNotVisible('eip155:1'); - await BrowserPlaygroundDapp.assertScopeCardVisible( - SOLANA_MAINNET_CAIP_CHAIN_ID, - ); - - await BrowserPlaygroundDapp.assertConnected(false); - await BrowserPlaygroundDapp.assertWagmiConnected(false); - await BrowserPlaygroundDapp.assertSolanaConnected(true); - - // Reconnect EVM - await PlaywrightGestures.scrollIntoView( - await asPlaywrightElement(BrowserPlaygroundDapp.connectWagmiButton), - { scrollParams: { direction: 'down' } }, - ); - await BrowserPlaygroundDapp.tapConnectWagmi(); - }, DAPP_URL); - - await PlaywrightContextHelpers.withNativeAction(async () => { - await AndroidScreenHelpers.tapOpenDeeplinkWithMetaMask(); - await DappConnectionModal.tapConnectButton({ shouldCooldown: true }); - }); - - await sleep(1000); - await switchToMobileBrowser(); - await sleep(1000); - - await PlaywrightContextHelpers.withWebAction(async () => { - await PlaywrightGestures.scrollIntoView( - await asPlaywrightElement(BrowserPlaygroundDapp.wagmiCard), - { scrollParams: { direction: 'up' } }, - ); - - await BrowserPlaygroundDapp.assertScopeCardVisible('eip155:1'); - - await BrowserPlaygroundDapp.assertConnected(true); - await BrowserPlaygroundDapp.assertChainIdValue('0x1'); - await BrowserPlaygroundDapp.assertActiveAccount(ACCOUNT_1_EVM_ADDRESS); - - await BrowserPlaygroundDapp.assertWagmiConnected(true); - await BrowserPlaygroundDapp.assertWagmiChainIdValue('1'); - await BrowserPlaygroundDapp.assertWagmiActiveAccount(ACCOUNT_1_EVM_ADDRESS); - // Verify wagmi personal sign works when wagmi is connected - await BrowserPlaygroundDapp.typeWagmiSignMessage('Hello MetaMask'); - await PlaywrightGestures.hideKeyboard(); - await BrowserPlaygroundDapp.tapWagmiSignMessage(); - }, DAPP_URL); - - await PlaywrightContextHelpers.withNativeAction(async () => { - await AndroidScreenHelpers.tapOpenDeeplinkWithMetaMask(); - await SignModal.tapConfirmButton(); - }); - - await sleep(1000); - await switchToMobileBrowser(); - await sleep(1000); - - await PlaywrightContextHelpers.withWebAction(async () => { - await BrowserPlaygroundDapp.assertWagmiSignatureResult('0x'); - - // Make sure solana is still connected - await BrowserPlaygroundDapp.assertScopeCardVisible( - SOLANA_MAINNET_CAIP_CHAIN_ID, - ); - await BrowserPlaygroundDapp.assertSolanaConnected(true); - await BrowserPlaygroundDapp.assertSolanaActiveAccount( - ACCOUNT_1_SOLANA_ADDRESS, - ); - // Verify solana sign works when solana is connected - await BrowserPlaygroundDapp.tapSolanaSignMessage(); - }, DAPP_URL); - - await PlaywrightContextHelpers.withNativeAction(async () => { - await AndroidScreenHelpers.tapOpenDeeplinkWithMetaMask(); - await SnapSignModal.tapConfirmButton(); - }); - - await sleep(1000); - await switchToMobileBrowser(); - await sleep(1000); - - await PlaywrightContextHelpers.withWebAction(async () => { - await PlaywrightGestures.scrollIntoView( - await asPlaywrightElement( - BrowserPlaygroundDapp.solanaSignedMessageResult, - ), - ); - await BrowserPlaygroundDapp.assertSolanaSignedMessageResult( - ACCOUNT_1_SOLANA_SIGNED_MESSAGE_RESULT, - ); - }, DAPP_URL); -}); +}); // end describe diff --git a/tests/performance/mm-connect/connection-wagmi-chains.spec.ts b/tests/performance/mm-connect/connection-wagmi-chains.spec.ts index ea88ce63c57..3ac86fe3e59 100644 --- a/tests/performance/mm-connect/connection-wagmi-chains.spec.ts +++ b/tests/performance/mm-connect/connection-wagmi-chains.spec.ts @@ -1,4 +1,5 @@ import { test } from '../../framework/fixture'; +import { Performance } from '../../tags.performance.js'; import { loginToAppPlaywright } from '../../flows/wallet.flow'; import WalletView from '../../page-objects/wallet/WalletView'; @@ -45,236 +46,238 @@ const playgroundServer = new DappServer({ dappVariant: DappVariants.BROWSER_PLAYGROUND, }); -// Start local playground server before all tests -test.beforeAll(async () => { - playgroundServer.setServerPort(DAPP_PORT); - await playgroundServer.start(); - await waitForDappServerReady(DAPP_PORT); - setupAdbReverse(DAPP_PORT); -}); - -// Stop local playground server after all tests -test.afterAll(async () => { - cleanupAdbReverse(DAPP_PORT); - await playgroundServer.stop(); -}); +test.describe(Performance, () => { + // Start local playground server before all tests + test.beforeAll(async () => { + playgroundServer.setServerPort(DAPP_PORT); + await playgroundServer.start(); + await waitForDappServerReady(DAPP_PORT); + setupAdbReverse(DAPP_PORT); + }); -// Test steps (in order): -// -// 1. LOGIN AND NAVIGATE TO DAPP -// - Login to app, ensure account groups finished loading -// - Launch mobile browser and navigate to the playground dapp -// -// 2. CONNECT VIA WAGMI -// - Tap Connect (Wagmi) -// - In MetaMask: add Account 3, unselect OP Mainnet from networks, tap Connect -// - Assert: wagmi connected, chainId 1, active account is Account 1 -// -// 3. SWITCH TO SEPOLIA AND SIGN -// - Switch chain to Sepolia (11155111), assert chainId 11155111 -// - Type 'Hello Sepolia' and tap sign -// - In MetaMask: cancel sign request on Sepolia network -// -// 4. SWITCH TO OP MAINNET (requires approval) AND CHANGE ACCOUNT -// - Tap switch chain to OP Mainnet (10) -// - In MetaMask: approve chain switch modal showing 'OP' -// - Assert: chainId 10 -// - Type 'Hello OP' and tap sign; cancel in MetaMask -// - In MetaMask: navigate to account list and select Account 3 -// -// 5. VERIFY ACCOUNT CHANGE AND ADD CELO CHAIN -// - Assert: wagmi active account is now Account 3 -// - Tap switch chain to Celo (42220) — triggers add chain flow -// - In MetaMask: assert chain shows '42220' and 'Celo', confirm add chain -// - Assert: chainId 42220 -// - Type 'Hello Celo' and tap sign; cancel in MetaMask -// -// 6. CLEANUP -// - Tap disconnect to clean up -test('@metamask/connect-evm (wagmi) - Chain switching via Wagmi', async ({ - currentDeviceDetails, - driver, -}) => { - const platform = currentDeviceDetails.platform; - const useBrowserStackLocal = - process.env.BROWSERSTACK_LOCAL?.toLowerCase() === 'true'; - const DAPP_URL = useBrowserStackLocal - ? `http://bs-local.com:${DAPP_PORT}` - : getDappUrlForBrowser(platform); + // Stop local playground server after all tests + test.afterAll(async () => { + cleanupAdbReverse(DAPP_PORT); + await playgroundServer.stop(); + }); + // Test steps (in order): // - // Login and navigate to dapp + // 1. LOGIN AND NAVIGATE TO DAPP + // - Login to app, ensure account groups finished loading + // - Launch mobile browser and navigate to the playground dapp // - await PlaywrightContextHelpers.withNativeAction(async () => { - await loginToAppPlaywright(); - await ensureAccountGroupsFinishedLoading(currentDeviceDetails); - await launchMobileBrowser(); - await navigateToDapp(DAPP_URL); - }); - - await sleep(5000); - + // 2. CONNECT VIA WAGMI + // - Tap Connect (Wagmi) + // - In MetaMask: add Account 3, unselect OP Mainnet from networks, tap Connect + // - Assert: wagmi connected, chainId 1, active account is Account 1 // - // Connect via WAGMI + // 3. SWITCH TO SEPOLIA AND SIGN + // - Switch chain to Sepolia (11155111), assert chainId 11155111 + // - Type 'Hello Sepolia' and tap sign + // - In MetaMask: cancel sign request on Sepolia network // - - await PlaywrightContextHelpers.withWebAction(async () => { - await BrowserPlaygroundDapp.tapConnectWagmi(); - }, DAPP_URL); - - // Handle connection approval in MetaMask - await PlaywrightContextHelpers.withNativeAction(async () => { - await AndroidScreenHelpers.tapOpenDeeplinkWithMetaMask(); - await unlockIfLockScreenVisible(); - await DappConnectionModal.tapEditAccountsButton(); - // Select account 3 in addition to Account 1 - await DappConnectionModal.tapAccountButton('Account 3'); - await DappConnectionModal.tapUpdateAccountsButton(); - await DappConnectionModal.tapPermissionsTabButton(); - // Unselect OP Mainnet - await DappConnectionModal.tapEditNetworksButton(); - await DappConnectionModal.tapNetworkButton('OP'); - await DappConnectionModal.tapUpdateNetworksButton(); - await DappConnectionModal.tapConnectButton({ - shouldCooldown: true, - timeToCooldown: 2000, - }); - }); - - await sleep(1000); - await switchToMobileBrowser(); - await sleep(1000); - + // 4. SWITCH TO OP MAINNET (requires approval) AND CHANGE ACCOUNT + // - Tap switch chain to OP Mainnet (10) + // - In MetaMask: approve chain switch modal showing 'OP' + // - Assert: chainId 10 + // - Type 'Hello OP' and tap sign; cancel in MetaMask + // - In MetaMask: navigate to account list and select Account 3 // - // Verify connection and switch to Sepolia + // 5. VERIFY ACCOUNT CHANGE AND ADD CELO CHAIN + // - Assert: wagmi active account is now Account 3 + // - Tap switch chain to Celo (42220) — triggers add chain flow + // - In MetaMask: assert chain shows '42220' and 'Celo', confirm add chain + // - Assert: chainId 42220 + // - Type 'Hello Celo' and tap sign; cancel in MetaMask // - - await PlaywrightContextHelpers.withWebAction(async () => { - await BrowserPlaygroundDapp.assertWagmiConnected(true); - await BrowserPlaygroundDapp.assertWagmiChainIdValue('1'); - await BrowserPlaygroundDapp.assertWagmiActiveAccount(ACCOUNT_1_ADDRESS); - // Switch to Sepolia - await BrowserPlaygroundDapp.tapWagmiSwitchChain(11155111); - await BrowserPlaygroundDapp.assertWagmiChainIdValue('11155111'); - // Sign a message on Sepolia - await BrowserPlaygroundDapp.typeWagmiSignMessage('Hello Sepolia'); - await PlaywrightGestures.hideKeyboard(); - await BrowserPlaygroundDapp.tapWagmiSignMessage({ - shouldCooldown: true, - timeToCooldown: 2000, + // 6. CLEANUP + // - Tap disconnect to clean up + test('@metamask/connect-evm (wagmi) - Chain switching via Wagmi', async ({ + currentDeviceDetails, + driver, + }) => { + const platform = currentDeviceDetails.platform; + const useBrowserStackLocal = + process.env.BROWSERSTACK_LOCAL?.toLowerCase() === 'true'; + const DAPP_URL = useBrowserStackLocal + ? `http://bs-local.com:${DAPP_PORT}` + : getDappUrlForBrowser(platform); + + // + // Login and navigate to dapp + // + await PlaywrightContextHelpers.withNativeAction(async () => { + await loginToAppPlaywright(); + await ensureAccountGroupsFinishedLoading(currentDeviceDetails); + await launchMobileBrowser(); + await navigateToDapp(DAPP_URL); }); - }, DAPP_URL); - - // Cancel sign request - await PlaywrightContextHelpers.withNativeAction(async () => { - await AndroidScreenHelpers.tapOpenDeeplinkWithMetaMask(); - await SignModal.assertNetworkText('Sepolia'); - await SignModal.tapCancelButton(); - }); - - await sleep(1000); - await switchToMobileBrowser(); - await sleep(1000); - // - // Switch to OP Mainnet (requires approval since unselected earlier) - // + await sleep(5000); + + // + // Connect via WAGMI + // + + await PlaywrightContextHelpers.withWebAction(async () => { + await BrowserPlaygroundDapp.tapConnectWagmi(); + }, DAPP_URL); + + // Handle connection approval in MetaMask + await PlaywrightContextHelpers.withNativeAction(async () => { + await AndroidScreenHelpers.tapOpenDeeplinkWithMetaMask(); + await unlockIfLockScreenVisible(); + await DappConnectionModal.tapEditAccountsButton(); + // Select account 3 in addition to Account 1 + await DappConnectionModal.tapAccountButton('Account 3'); + await DappConnectionModal.tapUpdateAccountsButton(); + await DappConnectionModal.tapPermissionsTabButton(); + // Unselect OP Mainnet + await DappConnectionModal.tapEditNetworksButton(); + await DappConnectionModal.tapNetworkButton('OP'); + await DappConnectionModal.tapUpdateNetworksButton(); + await DappConnectionModal.tapConnectButton({ + shouldCooldown: true, + timeToCooldown: 2000, + }); + }); - await PlaywrightContextHelpers.withWebAction(async () => { - await BrowserPlaygroundDapp.tapWagmiSwitchChain(10); // OP Mainnet - }, DAPP_URL); + await sleep(1000); + await switchToMobileBrowser(); + await sleep(1000); - await PlaywrightContextHelpers.withNativeAction(async () => { - await AndroidScreenHelpers.tapOpenDeeplinkWithMetaMask(); - await SwitchChainModal.assertNetworkText('OP'); - await SwitchChainModal.tapConnectButton({ - shouldCooldown: true, - timeToCooldown: 2000, + // + // Verify connection and switch to Sepolia + // + + await PlaywrightContextHelpers.withWebAction(async () => { + await BrowserPlaygroundDapp.assertWagmiConnected(true); + await BrowserPlaygroundDapp.assertWagmiChainIdValue('1'); + await BrowserPlaygroundDapp.assertWagmiActiveAccount(ACCOUNT_1_ADDRESS); + // Switch to Sepolia + await BrowserPlaygroundDapp.tapWagmiSwitchChain(11155111); + await BrowserPlaygroundDapp.assertWagmiChainIdValue('11155111'); + // Sign a message on Sepolia + await BrowserPlaygroundDapp.typeWagmiSignMessage('Hello Sepolia'); + await PlaywrightGestures.hideKeyboard(); + await BrowserPlaygroundDapp.tapWagmiSignMessage({ + shouldCooldown: true, + timeToCooldown: 2000, + }); + }, DAPP_URL); + + // Cancel sign request + await PlaywrightContextHelpers.withNativeAction(async () => { + await AndroidScreenHelpers.tapOpenDeeplinkWithMetaMask(); + await SignModal.assertNetworkText('Sepolia'); + await SignModal.tapCancelButton(); }); - }); - - await sleep(1000); - await switchToMobileBrowser(); - await sleep(1000); - await PlaywrightContextHelpers.withWebAction(async () => { - await BrowserPlaygroundDapp.assertWagmiChainIdValue('10'); - await BrowserPlaygroundDapp.typeWagmiSignMessage('Hello OP'); - await PlaywrightGestures.hideKeyboard(); - await BrowserPlaygroundDapp.tapWagmiSignMessage(); - }, DAPP_URL); + await sleep(1000); + await switchToMobileBrowser(); + await sleep(1000); - await PlaywrightContextHelpers.withNativeAction(async () => { - await AndroidScreenHelpers.tapOpenDeeplinkWithMetaMask(); - await SignModal.assertNetworkText('OP'); - await SignModal.tapCancelButton(); + // + // Switch to OP Mainnet (requires approval since unselected earlier) + // + + await PlaywrightContextHelpers.withWebAction(async () => { + await BrowserPlaygroundDapp.tapWagmiSwitchChain(10); // OP Mainnet + }, DAPP_URL); + + await PlaywrightContextHelpers.withNativeAction(async () => { + await AndroidScreenHelpers.tapOpenDeeplinkWithMetaMask(); + await SwitchChainModal.assertNetworkText('OP'); + await SwitchChainModal.tapConnectButton({ + shouldCooldown: true, + timeToCooldown: 2000, + }); + }); - // Wait here to make sure UI is visible before attempted interaction + await sleep(1000); + await switchToMobileBrowser(); await sleep(1000); - // Change selected account to Account 3 in MetaMask - await WalletView.tapIdenticon(); - await AccountListBottomSheet.tapAccountByName('Account 3'); - // Forcefully waiting for accounts to be synced - await sleep(2500); - }); - - await sleep(1000); - await switchToMobileBrowser(); - await sleep(1000); + await PlaywrightContextHelpers.withWebAction(async () => { + await BrowserPlaygroundDapp.assertWagmiChainIdValue('10'); + await BrowserPlaygroundDapp.typeWagmiSignMessage('Hello OP'); + await PlaywrightGestures.hideKeyboard(); + await BrowserPlaygroundDapp.tapWagmiSignMessage(); + }, DAPP_URL); + + await PlaywrightContextHelpers.withNativeAction(async () => { + await AndroidScreenHelpers.tapOpenDeeplinkWithMetaMask(); + await SignModal.assertNetworkText('OP'); + await SignModal.tapCancelButton(); + + // Wait here to make sure UI is visible before attempted interaction + await sleep(1000); + + // Change selected account to Account 3 in MetaMask + await WalletView.tapIdenticon(); + await AccountListBottomSheet.tapAccountByName('Account 3'); + // Forcefully waiting for accounts to be synced + await sleep(2500); + }); - // - // Verify account change and add CELO chain - // + await sleep(1000); + await switchToMobileBrowser(); + await sleep(1000); - await PlaywrightContextHelpers.withWebAction(async () => { - await BrowserPlaygroundDapp.assertWagmiActiveAccount(ACCOUNT_3_ADDRESS); - // Try to switch to Celo (will trigger add chain) - await BrowserPlaygroundDapp.tapWagmiSwitchChain(42220); - }, DAPP_URL); - - await PlaywrightContextHelpers.withNativeAction(async () => { - await AndroidScreenHelpers.tapOpenDeeplinkWithMetaMask(); - await AddChainModal.assertText('42220'); - await AddChainModal.assertText('Celo'); - await AddChainModal.tapConfirmButton({ - shouldCooldown: true, - timeToCooldown: 2000, + // + // Verify account change and add CELO chain + // + + await PlaywrightContextHelpers.withWebAction(async () => { + await BrowserPlaygroundDapp.assertWagmiActiveAccount(ACCOUNT_3_ADDRESS); + // Try to switch to Celo (will trigger add chain) + await BrowserPlaygroundDapp.tapWagmiSwitchChain(42220); + }, DAPP_URL); + + await PlaywrightContextHelpers.withNativeAction(async () => { + await AndroidScreenHelpers.tapOpenDeeplinkWithMetaMask(); + await AddChainModal.assertText('42220'); + await AddChainModal.assertText('Celo'); + await AddChainModal.tapConfirmButton({ + shouldCooldown: true, + timeToCooldown: 2000, + }); }); - }); - await sleep(1000); - await switchToMobileBrowser(); - await sleep(1000); - - await PlaywrightContextHelpers.withWebAction(async () => { - await BrowserPlaygroundDapp.assertWagmiChainIdValue('42220'); - await BrowserPlaygroundDapp.typeWagmiSignMessage('Hello Celo'); - await PlaywrightGestures.hideKeyboard(); - await BrowserPlaygroundDapp.tapWagmiSignMessage({ - shouldCooldown: true, - timeToCooldown: 2000, - }); - }, DAPP_URL); - - await PlaywrightContextHelpers.withNativeAction(async () => { - await AndroidScreenHelpers.tapOpenDeeplinkWithMetaMask(); - await SignModal.assertNetworkText('Celo'); - await SignModal.tapCancelButton({ - shouldCooldown: true, - timeToCooldown: 2000, + await sleep(1000); + await switchToMobileBrowser(); + await sleep(1000); + + await PlaywrightContextHelpers.withWebAction(async () => { + await BrowserPlaygroundDapp.assertWagmiChainIdValue('42220'); + await BrowserPlaygroundDapp.typeWagmiSignMessage('Hello Celo'); + await PlaywrightGestures.hideKeyboard(); + await BrowserPlaygroundDapp.tapWagmiSignMessage({ + shouldCooldown: true, + timeToCooldown: 2000, + }); + }, DAPP_URL); + + await PlaywrightContextHelpers.withNativeAction(async () => { + await AndroidScreenHelpers.tapOpenDeeplinkWithMetaMask(); + await SignModal.assertNetworkText('Celo'); + await SignModal.tapCancelButton({ + shouldCooldown: true, + timeToCooldown: 2000, + }); }); - }); - await sleep(1000); - await switchToMobileBrowser(); - await sleep(1000); + await sleep(1000); + await switchToMobileBrowser(); + await sleep(1000); - // - // Reset dapp state - // + // + // Reset dapp state + // - await PlaywrightContextHelpers.withWebAction(async () => { - await BrowserPlaygroundDapp.tapDisconnect(); - }, DAPP_URL); -}); + await PlaywrightContextHelpers.withWebAction(async () => { + await BrowserPlaygroundDapp.tapDisconnect(); + }, DAPP_URL); + }); +}); // end describe diff --git a/tests/performance/mm-connect/connection-wagmi.spec.ts b/tests/performance/mm-connect/connection-wagmi.spec.ts index 3f3d410e455..0d32aeea7b3 100644 --- a/tests/performance/mm-connect/connection-wagmi.spec.ts +++ b/tests/performance/mm-connect/connection-wagmi.spec.ts @@ -1,4 +1,5 @@ import { test } from '../../framework/fixture'; +import { Performance } from '../../tags.performance.js'; import { loginToAppPlaywright } from '../../flows/wallet.flow'; import BrowserPlaygroundDapp from '../../page-objects/MMConnect/BrowserPlaygroundDapp'; @@ -41,235 +42,237 @@ const playgroundServer = new DappServer({ dappVariant: DappVariants.BROWSER_PLAYGROUND, }); -// Start local playground server before all tests -test.beforeAll(async () => { - playgroundServer.setServerPort(DAPP_PORT); - await playgroundServer.start(); - await waitForDappServerReady(DAPP_PORT); - setupAdbReverse(DAPP_PORT); -}); - -// Stop local playground server after all tests -test.afterAll(async () => { - cleanupAdbReverse(DAPP_PORT); - await playgroundServer.stop(); -}); +test.describe(Performance, () => { + // Start local playground server before all tests + test.beforeAll(async () => { + playgroundServer.setServerPort(DAPP_PORT); + await playgroundServer.start(); + await waitForDappServerReady(DAPP_PORT); + setupAdbReverse(DAPP_PORT); + }); -// Test steps (in order): -// -// 1. LOGIN AND NAVIGATE TO DAPP -// - Login to app, ensure account groups finished loading -// - Launch mobile browser and navigate to the playground dapp -// -// 2. CONNECT VIA WAGMI -// - Tap Connect (Wagmi) -// - In MetaMask: approve connection -// - Assert: wagmi connected, chainId 1, active account is Account 1 -// -// 3. SIGN MESSAGE ON ETHEREUM -// - Type 'Hello MetaMask' and tap sign -// - In MetaMask: confirm sign request on Ethereum network -// - Assert: signature result starts with '0x' -// -// 4. REFRESH BROWSER RECONNECT -// - Refresh mobile browser -// - Assert: wagmi still connected, chainId 1, active account is Account 1 -// - Type 'After refresh' and tap sign; cancel in MetaMask -// -// 5. DISCONNECT AND RECONNECT -// - Tap disconnect, assert wagmi disconnected -// - Tap Connect (Wagmi), approve in MetaMask -// - Assert: wagmi connected, chainId 1, active account is Account 1 -// -// 6. INCOMPLETE SESSION TIMEOUT -// - Disconnect, then tap Connect (Wagmi) -// - In MetaMask: open approval but do NOT interact -// - Refresh mobile browser, wait 10s for session to time out -// - Assert: wagmi disconnected -// - Tap Connect (Wagmi) again, approve in MetaMask -// - Assert: wagmi connected, chainId 1, active account is Account 1 -// -// 7. RESET DAPP STATE -// - Tap disconnect to clean up - -// This test is currently failing. See 250. -test.skip('@metamask/connect-evm (wagmi) - Session stability via Wagmi', async ({ - currentDeviceDetails, - driver, -}) => { - const platform = currentDeviceDetails.platform; - const useBrowserStackLocal = - process.env.BROWSERSTACK_LOCAL?.toLowerCase() === 'true'; - const DAPP_URL = useBrowserStackLocal - ? `http://bs-local.com:${DAPP_PORT}` - : getDappUrlForBrowser(platform); + // Stop local playground server after all tests + test.afterAll(async () => { + cleanupAdbReverse(DAPP_PORT); + await playgroundServer.stop(); + }); + // Test steps (in order): // - // Login and navigate to dapp + // 1. LOGIN AND NAVIGATE TO DAPP + // - Login to app, ensure account groups finished loading + // - Launch mobile browser and navigate to the playground dapp // - await PlaywrightContextHelpers.withNativeAction(async () => { - await loginToAppPlaywright(); - await ensureAccountGroupsFinishedLoading(currentDeviceDetails); - await launchMobileBrowser(); - await navigateToDapp(DAPP_URL); - }); - - await sleep(5000); - + // 2. CONNECT VIA WAGMI + // - Tap Connect (Wagmi) + // - In MetaMask: approve connection + // - Assert: wagmi connected, chainId 1, active account is Account 1 + // + // 3. SIGN MESSAGE ON ETHEREUM + // - Type 'Hello MetaMask' and tap sign + // - In MetaMask: confirm sign request on Ethereum network + // - Assert: signature result starts with '0x' + // + // 4. REFRESH BROWSER RECONNECT + // - Refresh mobile browser + // - Assert: wagmi still connected, chainId 1, active account is Account 1 + // - Type 'After refresh' and tap sign; cancel in MetaMask // - // Connect via WAGMI + // 5. DISCONNECT AND RECONNECT + // - Tap disconnect, assert wagmi disconnected + // - Tap Connect (Wagmi), approve in MetaMask + // - Assert: wagmi connected, chainId 1, active account is Account 1 // + // 6. INCOMPLETE SESSION TIMEOUT + // - Disconnect, then tap Connect (Wagmi) + // - In MetaMask: open approval but do NOT interact + // - Refresh mobile browser, wait 10s for session to time out + // - Assert: wagmi disconnected + // - Tap Connect (Wagmi) again, approve in MetaMask + // - Assert: wagmi connected, chainId 1, active account is Account 1 + // + // 7. RESET DAPP STATE + // - Tap disconnect to clean up + + // This test is currently failing. See 250. + test.skip('@metamask/connect-evm (wagmi) - Session stability via Wagmi', async ({ + currentDeviceDetails, + driver, + }) => { + const platform = currentDeviceDetails.platform; + const useBrowserStackLocal = + process.env.BROWSERSTACK_LOCAL?.toLowerCase() === 'true'; + const DAPP_URL = useBrowserStackLocal + ? `http://bs-local.com:${DAPP_PORT}` + : getDappUrlForBrowser(platform); + + // + // Login and navigate to dapp + // + await PlaywrightContextHelpers.withNativeAction(async () => { + await loginToAppPlaywright(); + await ensureAccountGroupsFinishedLoading(currentDeviceDetails); + await launchMobileBrowser(); + await navigateToDapp(DAPP_URL); + }); - await PlaywrightContextHelpers.withWebAction(async () => { - await BrowserPlaygroundDapp.tapConnectWagmi(); - }, DAPP_URL); + await sleep(5000); - await PlaywrightContextHelpers.withNativeAction(async () => { - await AndroidScreenHelpers.tapOpenDeeplinkWithMetaMask(); - await unlockIfLockScreenVisible(); - await DappConnectionModal.tapConnectButton(); - }); + // + // Connect via WAGMI + // - await sleep(1000); - await switchToMobileBrowser(); - await sleep(1000); + await PlaywrightContextHelpers.withWebAction(async () => { + await BrowserPlaygroundDapp.tapConnectWagmi(); + }, DAPP_URL); - // - // Verify connection and sign message on Ethereum - // + await PlaywrightContextHelpers.withNativeAction(async () => { + await AndroidScreenHelpers.tapOpenDeeplinkWithMetaMask(); + await unlockIfLockScreenVisible(); + await DappConnectionModal.tapConnectButton(); + }); - await PlaywrightContextHelpers.withWebAction(async () => { - await BrowserPlaygroundDapp.assertWagmiConnected(true); - await BrowserPlaygroundDapp.assertWagmiChainIdValue('1'); - await BrowserPlaygroundDapp.assertWagmiActiveAccount(ACCOUNT_1_ADDRESS); - await BrowserPlaygroundDapp.typeWagmiSignMessage('Hello MetaMask'); - await PlaywrightGestures.hideKeyboard(); - await BrowserPlaygroundDapp.tapWagmiSignMessage(); - }, DAPP_URL); - - await PlaywrightContextHelpers.withNativeAction(async () => { - await AndroidScreenHelpers.tapOpenDeeplinkWithMetaMask(); - await SignModal.assertNetworkText('Ethereum'); - await SignModal.tapConfirmButton(); - }); + await sleep(1000); + await switchToMobileBrowser(); + await sleep(1000); + + // + // Verify connection and sign message on Ethereum + // + + await PlaywrightContextHelpers.withWebAction(async () => { + await BrowserPlaygroundDapp.assertWagmiConnected(true); + await BrowserPlaygroundDapp.assertWagmiChainIdValue('1'); + await BrowserPlaygroundDapp.assertWagmiActiveAccount(ACCOUNT_1_ADDRESS); + await BrowserPlaygroundDapp.typeWagmiSignMessage('Hello MetaMask'); + await PlaywrightGestures.hideKeyboard(); + await BrowserPlaygroundDapp.tapWagmiSignMessage(); + }, DAPP_URL); + + await PlaywrightContextHelpers.withNativeAction(async () => { + await AndroidScreenHelpers.tapOpenDeeplinkWithMetaMask(); + await SignModal.assertNetworkText('Ethereum'); + await SignModal.tapConfirmButton(); + }); - await sleep(1000); - await switchToMobileBrowser(); - await sleep(1000); + await sleep(1000); + await switchToMobileBrowser(); + await sleep(1000); - await PlaywrightContextHelpers.withWebAction(async () => { - await BrowserPlaygroundDapp.assertWagmiSignatureResult('0x'); - }, DAPP_URL); + await PlaywrightContextHelpers.withWebAction(async () => { + await BrowserPlaygroundDapp.assertWagmiSignatureResult('0x'); + }, DAPP_URL); - // - // Resume from refresh - // + // + // Resume from refresh + // - await PlaywrightContextHelpers.withNativeAction(async () => { - await refreshMobileBrowser(); - }); - await sleep(2000); - - await PlaywrightContextHelpers.withWebAction(async () => { - await BrowserPlaygroundDapp.assertWagmiConnected(true); - // Note: Chain may reset to 1 after refresh - await BrowserPlaygroundDapp.assertWagmiChainIdValue('1'); - await BrowserPlaygroundDapp.assertWagmiActiveAccount(ACCOUNT_1_ADDRESS); - await BrowserPlaygroundDapp.typeWagmiSignMessage('After refresh'); - await PlaywrightGestures.hideKeyboard(); - await BrowserPlaygroundDapp.tapWagmiSignMessage(); - }, DAPP_URL); - - await PlaywrightContextHelpers.withNativeAction(async () => { - await AndroidScreenHelpers.tapOpenDeeplinkWithMetaMask(); - await SignModal.tapCancelButton(); - }); + await PlaywrightContextHelpers.withNativeAction(async () => { + await refreshMobileBrowser(); + }); + await sleep(2000); + + await PlaywrightContextHelpers.withWebAction(async () => { + await BrowserPlaygroundDapp.assertWagmiConnected(true); + // Note: Chain may reset to 1 after refresh + await BrowserPlaygroundDapp.assertWagmiChainIdValue('1'); + await BrowserPlaygroundDapp.assertWagmiActiveAccount(ACCOUNT_1_ADDRESS); + await BrowserPlaygroundDapp.typeWagmiSignMessage('After refresh'); + await PlaywrightGestures.hideKeyboard(); + await BrowserPlaygroundDapp.tapWagmiSignMessage(); + }, DAPP_URL); + + await PlaywrightContextHelpers.withNativeAction(async () => { + await AndroidScreenHelpers.tapOpenDeeplinkWithMetaMask(); + await SignModal.tapCancelButton(); + }); - await sleep(1000); - await switchToMobileBrowser(); - await sleep(1000); + await sleep(1000); + await switchToMobileBrowser(); + await sleep(1000); - // - // Terminate and connect - // + // + // Terminate and connect + // - await PlaywrightContextHelpers.withWebAction(async () => { - await BrowserPlaygroundDapp.tapDisconnect(); - await BrowserPlaygroundDapp.assertWagmiConnected(false); - await BrowserPlaygroundDapp.tapConnectWagmi(); - }, DAPP_URL); + await PlaywrightContextHelpers.withWebAction(async () => { + await BrowserPlaygroundDapp.tapDisconnect(); + await BrowserPlaygroundDapp.assertWagmiConnected(false); + await BrowserPlaygroundDapp.tapConnectWagmi(); + }, DAPP_URL); - await PlaywrightContextHelpers.withNativeAction(async () => { - await AndroidScreenHelpers.tapOpenDeeplinkWithMetaMask(); - await DappConnectionModal.tapConnectButton(); - }); + await PlaywrightContextHelpers.withNativeAction(async () => { + await AndroidScreenHelpers.tapOpenDeeplinkWithMetaMask(); + await DappConnectionModal.tapConnectButton(); + }); - await sleep(1000); - await switchToMobileBrowser(); - await sleep(1000); + await sleep(1000); + await switchToMobileBrowser(); + await sleep(1000); - await PlaywrightContextHelpers.withWebAction(async () => { - await BrowserPlaygroundDapp.assertWagmiConnected(true); - await BrowserPlaygroundDapp.assertWagmiChainIdValue('1'); - await BrowserPlaygroundDapp.assertWagmiActiveAccount(ACCOUNT_1_ADDRESS); - }, DAPP_URL); + await PlaywrightContextHelpers.withWebAction(async () => { + await BrowserPlaygroundDapp.assertWagmiConnected(true); + await BrowserPlaygroundDapp.assertWagmiChainIdValue('1'); + await BrowserPlaygroundDapp.assertWagmiActiveAccount(ACCOUNT_1_ADDRESS); + }, DAPP_URL); - // - // Wait for incomplete session timeout on refresh and reconnect after - // + // + // Wait for incomplete session timeout on refresh and reconnect after + // - await PlaywrightContextHelpers.withWebAction(async () => { - await BrowserPlaygroundDapp.tapDisconnect(); - await BrowserPlaygroundDapp.tapConnectWagmi(); - }, DAPP_URL); + await PlaywrightContextHelpers.withWebAction(async () => { + await BrowserPlaygroundDapp.tapDisconnect(); + await BrowserPlaygroundDapp.tapConnectWagmi(); + }, DAPP_URL); - await PlaywrightContextHelpers.withNativeAction(async () => { - await AndroidScreenHelpers.tapOpenDeeplinkWithMetaMask(); - // Purposely not interacting with the approval - }); + await PlaywrightContextHelpers.withNativeAction(async () => { + await AndroidScreenHelpers.tapOpenDeeplinkWithMetaMask(); + // Purposely not interacting with the approval + }); - await sleep(2000); - await switchToMobileBrowser(); - await sleep(1000); + await sleep(2000); + await switchToMobileBrowser(); + await sleep(1000); - await PlaywrightContextHelpers.withNativeAction(async () => { - await refreshMobileBrowser(); - }); - await sleep(2000); - - // After timeout, should be disconnected - await sleep(10000); - - await PlaywrightContextHelpers.withWebAction(async () => { - await BrowserPlaygroundDapp.assertWagmiConnected(false); - await BrowserPlaygroundDapp.tapConnectWagmi(); - }, DAPP_URL); - - await PlaywrightContextHelpers.withNativeAction(async () => { - await AndroidScreenHelpers.tapOpenDeeplinkWithMetaMask(); - // TODO: We're having a double connect prompt. After approving the - // connection, a second prompt with empty accounts is shown. - await DappConnectionModal.tapConnectButton({ - shouldCooldown: true, - timeToCooldown: 2000, + await PlaywrightContextHelpers.withNativeAction(async () => { + await refreshMobileBrowser(); + }); + await sleep(2000); + + // After timeout, should be disconnected + await sleep(10000); + + await PlaywrightContextHelpers.withWebAction(async () => { + await BrowserPlaygroundDapp.assertWagmiConnected(false); + await BrowserPlaygroundDapp.tapConnectWagmi(); + }, DAPP_URL); + + await PlaywrightContextHelpers.withNativeAction(async () => { + await AndroidScreenHelpers.tapOpenDeeplinkWithMetaMask(); + // TODO: We're having a double connect prompt. After approving the + // connection, a second prompt with empty accounts is shown. + await DappConnectionModal.tapConnectButton({ + shouldCooldown: true, + timeToCooldown: 2000, + }); }); - }); - await sleep(1000); - await switchToMobileBrowser(); - await sleep(1000); + await sleep(1000); + await switchToMobileBrowser(); + await sleep(1000); - await PlaywrightContextHelpers.withWebAction(async () => { - await BrowserPlaygroundDapp.assertWagmiConnected(true); - await BrowserPlaygroundDapp.assertWagmiChainIdValue('1'); - await BrowserPlaygroundDapp.assertWagmiActiveAccount(ACCOUNT_1_ADDRESS); - }, DAPP_URL); + await PlaywrightContextHelpers.withWebAction(async () => { + await BrowserPlaygroundDapp.assertWagmiConnected(true); + await BrowserPlaygroundDapp.assertWagmiChainIdValue('1'); + await BrowserPlaygroundDapp.assertWagmiActiveAccount(ACCOUNT_1_ADDRESS); + }, DAPP_URL); - // - // Reset dapp state - // + // + // Reset dapp state + // - await PlaywrightContextHelpers.withWebAction(async () => { - await BrowserPlaygroundDapp.tapDisconnect(); - }, DAPP_URL); -}); + await PlaywrightContextHelpers.withWebAction(async () => { + await BrowserPlaygroundDapp.tapDisconnect(); + }, DAPP_URL); + }); +}); // end describe diff --git a/tests/performance/mm-connect/legacy-evm-rn-connect.spec.ts b/tests/performance/mm-connect/legacy-evm-rn-connect.spec.ts index 55c04a0bd22..2561487efb2 100644 --- a/tests/performance/mm-connect/legacy-evm-rn-connect.spec.ts +++ b/tests/performance/mm-connect/legacy-evm-rn-connect.spec.ts @@ -1,4 +1,5 @@ import { test } from '../../framework/fixture'; +import { Performance } from '../../tags.performance.js'; import { loginToAppPlaywright } from '../../flows/wallet.flow'; import RNPlaygroundDapp from '../../page-objects/MMConnect/RNPlaygroundDapp'; @@ -17,160 +18,162 @@ async function returnToPlayground() { await RNPlaygroundDapp.ensureInPlayground(); } -test('@metamask/connect-legacy-evm-rn - Connect via Legacy EVM, sign, send transaction, and switch chains', async ({ - currentDeviceDetails, - driver, -}) => { - // When running on BrowserStack we skip the test if the RN playground is not installed - test.skip( - currentDeviceDetails.isBrowserstack && - !process.env.BROWSERSTACK_RN_PLAYGROUND_URL, - 'Skipped: BROWSERSTACK_RN_PLAYGROUND_URL is not set', - ); - - // handle local installs of the RN playground - if (!currentDeviceDetails.isBrowserstack) { - ensurePlaygroundInstalled(currentDeviceDetails); - } - - // - // 1. Login to MetaMask wallet - // - await loginToAppPlaywright(); - - // - // 2. Switch to the RN playground and connect via Legacy EVM - // - await RNPlaygroundDapp.switchToPlayground(); - await RNPlaygroundDapp.waitForPlaygroundReady(); - - await RNPlaygroundDapp.tapConnectLegacy(); - await sleep(3000); - - await unlockIfLockScreenVisible(); - await sleep(5000); - await DappConnectionModal.tapConnectButton({ - shouldCooldown: true, - timeToCooldown: 3000, - }); - - // - // 3. Verify accountsChanged — Legacy EVM card visible with accounts - // - - await returnToPlayground(); - await sleep(2000); - - await RNPlaygroundDapp.scrollToElement(RNPlaygroundDapp.appTitle, { - scrollParams: { direction: 'down' }, - }); - await RNPlaygroundDapp.scrollToElement(RNPlaygroundDapp.legacyEvmCard); - await RNPlaygroundDapp.assertLegacyEvmConnected(); - await RNPlaygroundDapp.assertLegacyEvmHasAccounts(); - await RNPlaygroundDapp.assertLegacyEvmActiveAccount(); - - const initialChainId = await RNPlaygroundDapp.getLegacyEvmChainId(); - console.log(`Initial chain ID: ${initialChainId}`); - - // - // 4. personal_sign — request, approve, verify result - // - await RNPlaygroundDapp.scrollToElement( - RNPlaygroundDapp.legacyEvmBtnPersonalSign, - ); - await RNPlaygroundDapp.tapLegacyEvmButton( - RNPlaygroundDapp.legacyEvmBtnPersonalSign, - ); - await sleep(3000); - - await unlockIfLockScreenVisible(); - await sleep(1000); - await SignModal.tapConfirmButton({ - shouldCooldown: true, - timeToCooldown: 3000, - }); - - await returnToPlayground(); - await sleep(1000); - - // Verify signature was returned (hex string starting with 0x) - await RNPlaygroundDapp.scrollToElement( - RNPlaygroundDapp.legacyEvmResponseText, - ); - const signResponse = await RNPlaygroundDapp.getLegacyEvmResponseText(); - console.log(`personal_sign response: ${signResponse}`); - console.log(`personal_sign contains 0x: ${signResponse.includes('0x')}`); - - // - // 5. eth_sendTransaction — request, cancel (to avoid spending funds) - // - await RNPlaygroundDapp.scrollToElement( - RNPlaygroundDapp.legacyEvmBtnSendTransaction, - ); - await RNPlaygroundDapp.tapLegacyEvmButton( - RNPlaygroundDapp.legacyEvmBtnSendTransaction, - ); - await sleep(3000); - - await unlockIfLockScreenVisible(); - await sleep(1000); - - // Cancel the transaction to avoid spending real funds - await SignModal.tapCancelButton({ - shouldCooldown: true, - timeToCooldown: 3000, - }); - - await returnToPlayground(); - await sleep(1000); - - // The dapp should show an error (user rejected) in the response - - await RNPlaygroundDapp.scrollToElement( - RNPlaygroundDapp.legacyEvmResponseText, - { +test.describe(Performance, () => { + test('@metamask/connect-legacy-evm-rn - Connect via Legacy EVM, sign, send transaction, and switch chains', async ({ + currentDeviceDetails, + driver, + }) => { + // When running on BrowserStack we skip the test if the RN playground is not installed + test.skip( + currentDeviceDetails.isBrowserstack && + !process.env.BROWSERSTACK_RN_PLAYGROUND_URL, + 'Skipped: BROWSERSTACK_RN_PLAYGROUND_URL is not set', + ); + + // handle local installs of the RN playground + if (!currentDeviceDetails.isBrowserstack) { + ensurePlaygroundInstalled(currentDeviceDetails); + } + + // + // 1. Login to MetaMask wallet + // + await loginToAppPlaywright(); + + // + // 2. Switch to the RN playground and connect via Legacy EVM + // + await RNPlaygroundDapp.switchToPlayground(); + await RNPlaygroundDapp.waitForPlaygroundReady(); + + await RNPlaygroundDapp.tapConnectLegacy(); + await sleep(3000); + + await unlockIfLockScreenVisible(); + await sleep(5000); + await DappConnectionModal.tapConnectButton({ + shouldCooldown: true, + timeToCooldown: 3000, + }); + + // + // 3. Verify accountsChanged — Legacy EVM card visible with accounts + // + + await returnToPlayground(); + await sleep(2000); + + await RNPlaygroundDapp.scrollToElement(RNPlaygroundDapp.appTitle, { scrollParams: { direction: 'down' }, - percent: 0.5, - }, - ); - - const txResponse = await RNPlaygroundDapp.getLegacyEvmResponseText(); - console.log(`eth_sendTransaction (cancelled) response: ${txResponse}`); - console.log( - `eth_sendTransaction contains denied: ${txResponse.toLowerCase().includes('denied')}`, - ); - - // - // 6. Chain switching from the dapp — wallet_switchEthereumChain - // Switch to Polygon from the dapp, verify the chain ID updates. - // - - await RNPlaygroundDapp.scrollToElement( - RNPlaygroundDapp.legacyEvmBtnSwitchPolygon, - ); - await RNPlaygroundDapp.tapLegacyEvmButton( - RNPlaygroundDapp.legacyEvmBtnSwitchPolygon, - ); - await sleep(3000); - - // The switch opens MetaMask with a network approval dialog. - // The SwitchChainApproval dialog uses "connect-button" as its confirm testID. - await unlockIfLockScreenVisible(); - await sleep(1000); - await DappConnectionModal.tapConnectButton({ - shouldCooldown: true, - timeToCooldown: 3000, + }); + await RNPlaygroundDapp.scrollToElement(RNPlaygroundDapp.legacyEvmCard); + await RNPlaygroundDapp.assertLegacyEvmConnected(); + await RNPlaygroundDapp.assertLegacyEvmHasAccounts(); + await RNPlaygroundDapp.assertLegacyEvmActiveAccount(); + + const initialChainId = await RNPlaygroundDapp.getLegacyEvmChainId(); + console.log(`Initial chain ID: ${initialChainId}`); + + // + // 4. personal_sign — request, approve, verify result + // + await RNPlaygroundDapp.scrollToElement( + RNPlaygroundDapp.legacyEvmBtnPersonalSign, + ); + await RNPlaygroundDapp.tapLegacyEvmButton( + RNPlaygroundDapp.legacyEvmBtnPersonalSign, + ); + await sleep(3000); + + await unlockIfLockScreenVisible(); + await sleep(1000); + await SignModal.tapConfirmButton({ + shouldCooldown: true, + timeToCooldown: 3000, + }); + + await returnToPlayground(); + await sleep(1000); + + // Verify signature was returned (hex string starting with 0x) + await RNPlaygroundDapp.scrollToElement( + RNPlaygroundDapp.legacyEvmResponseText, + ); + const signResponse = await RNPlaygroundDapp.getLegacyEvmResponseText(); + console.log(`personal_sign response: ${signResponse}`); + console.log(`personal_sign contains 0x: ${signResponse.includes('0x')}`); + + // + // 5. eth_sendTransaction — request, cancel (to avoid spending funds) + // + await RNPlaygroundDapp.scrollToElement( + RNPlaygroundDapp.legacyEvmBtnSendTransaction, + ); + await RNPlaygroundDapp.tapLegacyEvmButton( + RNPlaygroundDapp.legacyEvmBtnSendTransaction, + ); + await sleep(3000); + + await unlockIfLockScreenVisible(); + await sleep(1000); + + // Cancel the transaction to avoid spending real funds + await SignModal.tapCancelButton({ + shouldCooldown: true, + timeToCooldown: 3000, + }); + + await returnToPlayground(); + await sleep(1000); + + // The dapp should show an error (user rejected) in the response + + await RNPlaygroundDapp.scrollToElement( + RNPlaygroundDapp.legacyEvmResponseText, + { + scrollParams: { direction: 'down' }, + percent: 0.5, + }, + ); + + const txResponse = await RNPlaygroundDapp.getLegacyEvmResponseText(); + console.log(`eth_sendTransaction (cancelled) response: ${txResponse}`); + console.log( + `eth_sendTransaction contains denied: ${txResponse.toLowerCase().includes('denied')}`, + ); + + // + // 6. Chain switching from the dapp — wallet_switchEthereumChain + // Switch to Polygon from the dapp, verify the chain ID updates. + // + + await RNPlaygroundDapp.scrollToElement( + RNPlaygroundDapp.legacyEvmBtnSwitchPolygon, + ); + await RNPlaygroundDapp.tapLegacyEvmButton( + RNPlaygroundDapp.legacyEvmBtnSwitchPolygon, + ); + await sleep(3000); + + // The switch opens MetaMask with a network approval dialog. + // The SwitchChainApproval dialog uses "connect-button" as its confirm testID. + await unlockIfLockScreenVisible(); + await sleep(1000); + await DappConnectionModal.tapConnectButton({ + shouldCooldown: true, + timeToCooldown: 3000, + }); + + await returnToPlayground(); + await sleep(2000); + + // Verify chain ID updated to Polygon (0x89) + await RNPlaygroundDapp.scrollToElement( + RNPlaygroundDapp.legacyEvmChainIdValue, + { scrollParams: { direction: 'down' } }, + ); + const polygonChainId = await RNPlaygroundDapp.getLegacyEvmChainId(); + console.log(`Chain ID after dapp switch to Polygon: ${polygonChainId}`); + console.log(`Chain ID contains 0x89: ${polygonChainId.includes('0x89')}`); }); - - await returnToPlayground(); - await sleep(2000); - - // Verify chain ID updated to Polygon (0x89) - await RNPlaygroundDapp.scrollToElement( - RNPlaygroundDapp.legacyEvmChainIdValue, - { scrollParams: { direction: 'down' } }, - ); - const polygonChainId = await RNPlaygroundDapp.getLegacyEvmChainId(); - console.log(`Chain ID after dapp switch to Polygon: ${polygonChainId}`); - console.log(`Chain ID contains 0x89: ${polygonChainId.includes('0x89')}`); -}); +}); // end describe diff --git a/tests/performance/mm-connect/multichain-rn-evm.spec.ts b/tests/performance/mm-connect/multichain-rn-evm.spec.ts index 6576f35939f..250be3e0f8d 100644 --- a/tests/performance/mm-connect/multichain-rn-evm.spec.ts +++ b/tests/performance/mm-connect/multichain-rn-evm.spec.ts @@ -1,4 +1,5 @@ import { test } from '../../framework/fixture'; +import { Performance } from '../../tags.performance.js'; import { loginToAppPlaywright } from '../../flows/wallet.flow'; import RNPlaygroundDapp from '../../page-objects/MMConnect/RNPlaygroundDapp'; @@ -77,145 +78,153 @@ async function returnToPlayground() { // - Assert session is disconnected // - Switch to MetaMask and unlock if needed to confirm no active session -test('@metamask/connect-multichain-rn-evm - Connect across 3 EVM chains, invoke read/write methods, and disconnect', async ({ - currentDeviceDetails, - driver, -}) => { - // When running on BrowserStack we skip the test if the RN playground is not installed - test.skip( - currentDeviceDetails.isBrowserstack && - !process.env.BROWSERSTACK_RN_PLAYGROUND_URL, - 'Skipped: BROWSERSTACK_RN_PLAYGROUND_URL is not set', - ); - - // handle local installs of the RN playground - if (!currentDeviceDetails.isBrowserstack) { - ensurePlaygroundInstalled(currentDeviceDetails); - } - - // - // 1. Login to MetaMask wallet - // - await loginToAppPlaywright(); - await PlaywrightAssertions.expectElementToBeVisible( - await asPlaywrightElement(WalletView.container), - { timeout: 15000 }, - ); - - await ensureAccountGroupsFinishedLoading(currentDeviceDetails); - - // - // 2. Switch to the RN playground and select networks - // - await RNPlaygroundDapp.switchToPlayground(); - await RNPlaygroundDapp.waitForPlaygroundReady(); - - // Ethereum (eip155:1) is selected by default; add two more EVM networks - await RNPlaygroundDapp.tapNetworkCheckbox(CHAINS.LINEA); - await RNPlaygroundDapp.tapNetworkCheckbox(CHAINS.POLYGON); - - // - // 3. Connect via Multichain API - // - await RNPlaygroundDapp.tapConnect(); - await sleep(3000); - - await unlockIfLockScreenVisible(); - await sleep(5000); - await DappConnectionModal.tapConnectButton({ - shouldCooldown: true, - timeToCooldown: 3000, - }); - - // - // 4. Return to playground, verify EVM connections, and invoke read requests - // - await returnToPlayground(); - await RNPlaygroundDapp.assertConnected(); - - for (const chain of EVM_CHAINS) { - await RNPlaygroundDapp.scrollToElement( - RNPlaygroundDapp.getInvokeButton(chain), - { - percent: 0.5, - }, +test.describe(Performance, () => { + test('@metamask/connect-multichain-rn-evm - Connect across 3 EVM chains, invoke read/write methods, and disconnect', async ({ + currentDeviceDetails, + driver, + }) => { + // When running on BrowserStack we skip the test if the RN playground is not installed + test.skip( + currentDeviceDetails.isBrowserstack && + !process.env.BROWSERSTACK_RN_PLAYGROUND_URL, + 'Skipped: BROWSERSTACK_RN_PLAYGROUND_URL is not set', ); - await RNPlaygroundDapp.tapInvoke(chain); - await sleep(5000); - await RNPlaygroundDapp.scrollToElement( - RNPlaygroundDapp.getResultCode(chain, 'eth_blockNumber'), - { - percent: 0.5, - }, - ); - await RNPlaygroundDapp.assertResultCodeContains( - chain, - 'eth_blockNumber', - '0x', + // handle local installs of the RN playground + if (!currentDeviceDetails.isBrowserstack) { + ensurePlaygroundInstalled(currentDeviceDetails); + } + + // + // 1. Login to MetaMask wallet + // + await loginToAppPlaywright(); + await PlaywrightAssertions.expectElementToBeVisible( + await asPlaywrightElement(WalletView.container), + { timeout: 15000 }, ); - // Test the write request - await RNPlaygroundDapp.selectMethod(chain, 'personal_sign', 10, 2, 'down'); + await ensureAccountGroupsFinishedLoading(currentDeviceDetails); - await RNPlaygroundDapp.scrollToElement( - RNPlaygroundDapp.getInvokeButton(chain), - { - percent: 0.5, - }, - ); - await RNPlaygroundDapp.tapInvoke(chain); + // + // 2. Switch to the RN playground and select networks + // + await RNPlaygroundDapp.switchToPlayground(); + await RNPlaygroundDapp.waitForPlaygroundReady(); + + // Ethereum (eip155:1) is selected by default; add two more EVM networks + await RNPlaygroundDapp.tapNetworkCheckbox(CHAINS.LINEA); + await RNPlaygroundDapp.tapNetworkCheckbox(CHAINS.POLYGON); + + // + // 3. Connect via Multichain API + // + await RNPlaygroundDapp.tapConnect(); await sleep(3000); - // Handle MetaMask sign approval await unlockIfLockScreenVisible(); - await sleep(1000); - - // Verify request was routed to the correct network - const networkName = NETWORK_DISPLAY_NAMES[chain]; - if (networkName) { - try { - await SignModal.assertNetworkText(networkName); - } catch { - // Network label may not appear for all signing modals; continue - } - } - - await SignModal.tapConfirmButton({ + await sleep(5000); + await DappConnectionModal.tapConnectButton({ shouldCooldown: true, timeToCooldown: 3000, }); + + // + // 4. Return to playground, verify EVM connections, and invoke read requests + // await returnToPlayground(); + await RNPlaygroundDapp.assertConnected(); + + for (const chain of EVM_CHAINS) { + await RNPlaygroundDapp.scrollToElement( + RNPlaygroundDapp.getInvokeButton(chain), + { + percent: 0.5, + }, + ); + await RNPlaygroundDapp.tapInvoke(chain); + await sleep(5000); + + await RNPlaygroundDapp.scrollToElement( + RNPlaygroundDapp.getResultCode(chain, 'eth_blockNumber'), + { + percent: 0.5, + }, + ); + await RNPlaygroundDapp.assertResultCodeContains( + chain, + 'eth_blockNumber', + '0x', + ); + + // Test the write request + await RNPlaygroundDapp.selectMethod( + chain, + 'personal_sign', + 10, + 2, + 'down', + ); + + await RNPlaygroundDapp.scrollToElement( + RNPlaygroundDapp.getInvokeButton(chain), + { + percent: 0.5, + }, + ); + await RNPlaygroundDapp.tapInvoke(chain); + await sleep(3000); + + // Handle MetaMask sign approval + await unlockIfLockScreenVisible(); + await sleep(1000); + + // Verify request was routed to the correct network + const networkName = NETWORK_DISPLAY_NAMES[chain]; + if (networkName) { + try { + await SignModal.assertNetworkText(networkName); + } catch { + // Network label may not appear for all signing modals; continue + } + } - // Verify a signature was returned (hex string starting with 0x) - await RNPlaygroundDapp.scrollToElement( - RNPlaygroundDapp.getResultCode(chain, 'personal_sign'), - { - percent: 0.5, - scrollParams: { direction: 'up' }, - }, - ); - await RNPlaygroundDapp.assertResultCodeContains( - chain, - 'personal_sign', - '0x', - ); - } - - // Eager swipe up as the disconnect button sits at the top of the Dapp - await PlaywrightGestures.swipe({ - scrollParams: { direction: 'down' }, - duration: 100, - from: { x: 100, y: 300 }, - to: { x: 100, y: 1700 }, - percent: 0.5, - }); + await SignModal.tapConfirmButton({ + shouldCooldown: true, + timeToCooldown: 3000, + }); + await returnToPlayground(); + + // Verify a signature was returned (hex string starting with 0x) + await RNPlaygroundDapp.scrollToElement( + RNPlaygroundDapp.getResultCode(chain, 'personal_sign'), + { + percent: 0.5, + scrollParams: { direction: 'up' }, + }, + ); + await RNPlaygroundDapp.assertResultCodeContains( + chain, + 'personal_sign', + '0x', + ); + } - // Scroll back to the top where the disconnect button lives - await RNPlaygroundDapp.scrollToElement(RNPlaygroundDapp.disconnectButton, { - scrollParams: { direction: 'down' }, - percent: 0.5, + // Eager swipe up as the disconnect button sits at the top of the Dapp + await PlaywrightGestures.swipe({ + scrollParams: { direction: 'down' }, + duration: 100, + from: { x: 100, y: 300 }, + to: { x: 100, y: 1700 }, + percent: 0.5, + }); + + // Scroll back to the top where the disconnect button lives + await RNPlaygroundDapp.scrollToElement(RNPlaygroundDapp.disconnectButton, { + scrollParams: { direction: 'down' }, + percent: 0.5, + }); + await RNPlaygroundDapp.tapDisconnect(); + await RNPlaygroundDapp.assertDisconnected(); }); - await RNPlaygroundDapp.tapDisconnect(); - await RNPlaygroundDapp.assertDisconnected(); -}); +}); // end describe diff --git a/tests/performance/mm-connect/multichain-rn-solana.spec.ts b/tests/performance/mm-connect/multichain-rn-solana.spec.ts index 7b7f48be8a2..3db661cff79 100644 --- a/tests/performance/mm-connect/multichain-rn-solana.spec.ts +++ b/tests/performance/mm-connect/multichain-rn-solana.spec.ts @@ -1,4 +1,5 @@ import { test } from '../../framework/fixture'; +import { Performance } from '../../tags.performance.js'; import { loginToAppPlaywright } from '../../flows/wallet.flow'; import RNPlaygroundDapp from '../../page-objects/MMConnect/RNPlaygroundDapp'; @@ -65,122 +66,124 @@ async function returnToPlayground() { // - Assert session is disconnected // - Switch to MetaMask and unlock if needed to confirm no active session -test('@metamask/connect-multichain-rn-solana - Connect with Solana, invoke signMessage, and disconnect', async ({ - currentDeviceDetails, - driver, -}) => { - // When running on BrowserStack we skip the test if the RN playground is not installed - test.skip( - currentDeviceDetails.isBrowserstack && - !process.env.BROWSERSTACK_RN_PLAYGROUND_URL, - 'Skipped: BROWSERSTACK_RN_PLAYGROUND_URL is not set', - ); - - // handle local installs of the RN playground - if (!currentDeviceDetails.isBrowserstack) { - ensurePlaygroundInstalled(currentDeviceDetails); - } - - // - // 1. Login to MetaMask wallet - // - await loginToAppPlaywright(); - await PlaywrightAssertions.expectElementToBeVisible( - await asPlaywrightElement(WalletView.container), - { timeout: 15000 }, - ); - - await ensureAccountGroupsFinishedLoading(currentDeviceDetails); - - // - // 2. Switch to the RN playground and select networks - // - await RNPlaygroundDapp.switchToPlayground(); - await RNPlaygroundDapp.waitForPlaygroundReady(); - - // Ethereum (eip155:1) is selected by default; add three more networks - await RNPlaygroundDapp.tapNetworkCheckbox(CHAINS.LINEA); - await RNPlaygroundDapp.tapNetworkCheckbox(CHAINS.POLYGON); - await RNPlaygroundDapp.tapNetworkCheckbox(CHAINS.SOLANA); - - // - // 3. Connect via Multichain API - // - await RNPlaygroundDapp.tapConnect(); - await sleep(3000); - - await unlockIfLockScreenVisible(); - await sleep(5000); - await DappConnectionModal.tapConnectButton({ - shouldCooldown: true, - timeToCooldown: 3000, - }); - - // - // 4. Return to playground and verify Solana connection is active - // - await returnToPlayground(); - - await RNPlaygroundDapp.assertConnected(); - - await RNPlaygroundDapp.scrollToElement(RNPlaygroundDapp.appTitle, { - scrollParams: { direction: 'down' }, - percent: 0.5, - }); - - await RNPlaygroundDapp.scrollToElement( - RNPlaygroundDapp.getScopeCard(CHAINS.SOLANA), - { +test.describe(Performance, () => { + test('@metamask/connect-multichain-rn-solana - Connect with Solana, invoke signMessage, and disconnect', async ({ + currentDeviceDetails, + driver, + }) => { + // When running on BrowserStack we skip the test if the RN playground is not installed + test.skip( + currentDeviceDetails.isBrowserstack && + !process.env.BROWSERSTACK_RN_PLAYGROUND_URL, + 'Skipped: BROWSERSTACK_RN_PLAYGROUND_URL is not set', + ); + + // handle local installs of the RN playground + if (!currentDeviceDetails.isBrowserstack) { + ensurePlaygroundInstalled(currentDeviceDetails); + } + + // + // 1. Login to MetaMask wallet + // + await loginToAppPlaywright(); + await PlaywrightAssertions.expectElementToBeVisible( + await asPlaywrightElement(WalletView.container), + { timeout: 15000 }, + ); + + await ensureAccountGroupsFinishedLoading(currentDeviceDetails); + + // + // 2. Switch to the RN playground and select networks + // + await RNPlaygroundDapp.switchToPlayground(); + await RNPlaygroundDapp.waitForPlaygroundReady(); + + // Ethereum (eip155:1) is selected by default; add three more networks + await RNPlaygroundDapp.tapNetworkCheckbox(CHAINS.LINEA); + await RNPlaygroundDapp.tapNetworkCheckbox(CHAINS.POLYGON); + await RNPlaygroundDapp.tapNetworkCheckbox(CHAINS.SOLANA); + + // + // 3. Connect via Multichain API + // + await RNPlaygroundDapp.tapConnect(); + await sleep(3000); + + await unlockIfLockScreenVisible(); + await sleep(5000); + await DappConnectionModal.tapConnectButton({ + shouldCooldown: true, + timeToCooldown: 3000, + }); + + // + // 4. Return to playground and verify Solana connection is active + // + await returnToPlayground(); + + await RNPlaygroundDapp.assertConnected(); + + await RNPlaygroundDapp.scrollToElement(RNPlaygroundDapp.appTitle, { + scrollParams: { direction: 'down' }, percent: 0.5, - }, - ); - await RNPlaygroundDapp.assertScopeCardVisible(CHAINS.SOLANA); - - // - // 5. Solana write request — signMessage - // - await RNPlaygroundDapp.scrollToElement( - RNPlaygroundDapp.getMethodSelect(CHAINS.SOLANA), - { - percent: 0.5, - }, - ); - await RNPlaygroundDapp.selectMethod(CHAINS.SOLANA, 'signMessage'); - - await RNPlaygroundDapp.scrollToElement( - RNPlaygroundDapp.getInvokeButton(CHAINS.SOLANA), - { - percent: 0.5, - }, - ); - await RNPlaygroundDapp.tapInvoke(CHAINS.SOLANA); - await sleep(3000); - - await unlockIfLockScreenVisible(); - await sleep(1000); - await SnapSignModal.tapConfirmButton({ - shouldCooldown: true, - timeToCooldown: 3000, - }); - await returnToPlayground(); - - await RNPlaygroundDapp.scrollToElement( - RNPlaygroundDapp.getResultCode(CHAINS.SOLANA, 'signMessage'), - { + }); + + await RNPlaygroundDapp.scrollToElement( + RNPlaygroundDapp.getScopeCard(CHAINS.SOLANA), + { + percent: 0.5, + }, + ); + await RNPlaygroundDapp.assertScopeCardVisible(CHAINS.SOLANA); + + // + // 5. Solana write request — signMessage + // + await RNPlaygroundDapp.scrollToElement( + RNPlaygroundDapp.getMethodSelect(CHAINS.SOLANA), + { + percent: 0.5, + }, + ); + await RNPlaygroundDapp.selectMethod(CHAINS.SOLANA, 'signMessage'); + + await RNPlaygroundDapp.scrollToElement( + RNPlaygroundDapp.getInvokeButton(CHAINS.SOLANA), + { + percent: 0.5, + }, + ); + await RNPlaygroundDapp.tapInvoke(CHAINS.SOLANA); + await sleep(3000); + + await unlockIfLockScreenVisible(); + await sleep(1000); + await SnapSignModal.tapConfirmButton({ + shouldCooldown: true, + timeToCooldown: 3000, + }); + await returnToPlayground(); + + await RNPlaygroundDapp.scrollToElement( + RNPlaygroundDapp.getResultCode(CHAINS.SOLANA, 'signMessage'), + { + percent: 0.5, + }, + ); + await RNPlaygroundDapp.waitForResult(CHAINS.SOLANA, 'signMessage'); + + // + // 6. Disconnect (wallet_revokeSession) and verify session termination + // + + // Scroll back to the top where the disconnect button lives + await RNPlaygroundDapp.scrollToElement(RNPlaygroundDapp.disconnectButton, { + scrollParams: { direction: 'down' }, percent: 0.5, - }, - ); - await RNPlaygroundDapp.waitForResult(CHAINS.SOLANA, 'signMessage'); - - // - // 6. Disconnect (wallet_revokeSession) and verify session termination - // - - // Scroll back to the top where the disconnect button lives - await RNPlaygroundDapp.scrollToElement(RNPlaygroundDapp.disconnectButton, { - scrollParams: { direction: 'down' }, - percent: 0.5, + }); + await RNPlaygroundDapp.tapDisconnect(); + await RNPlaygroundDapp.assertDisconnected(); }); - await RNPlaygroundDapp.tapDisconnect(); - await RNPlaygroundDapp.assertDisconnected(); -}); +}); // end describe diff --git a/tests/performance/onboarding/import-wallet.spec.ts b/tests/performance/onboarding/import-wallet.spec.ts index 13c05aacd0c..7ffd6b10762 100644 --- a/tests/performance/onboarding/import-wallet.spec.ts +++ b/tests/performance/onboarding/import-wallet.spec.ts @@ -1,7 +1,7 @@ import { test } from '../../framework/fixture'; import TimerHelper from '../../framework/TimerHelper'; import { getPasswordForScenario } from '../../framework/utils/TestConstants.js'; -import { PerformanceOnboarding } from '../../tags.performance.js'; +import { Performance, PerformanceOnboarding } from '../../tags.performance.js'; import OnboardingView from '../../page-objects/Onboarding/OnboardingView'; import { asPlaywrightElement, @@ -21,7 +21,7 @@ import { fetchProductionFeatureFlags } from '../feature-flag-helper'; const testEnvironment = process.env.E2E_PERFORMANCE_BUILD_VARIANT || ''; /* Scenario 4: Imported wallet with +50 accounts */ -test.describe(PerformanceOnboarding, () => { +test.describe(`${Performance} ${PerformanceOnboarding}`, () => { test.setTimeout(240000); test( 'Onboarding Import SRP with +50 accounts, SRP 3', diff --git a/tests/performance/onboarding/imported-wallet-account-creation.spec.ts b/tests/performance/onboarding/imported-wallet-account-creation.spec.ts index 686d434b373..c0d00d04aef 100644 --- a/tests/performance/onboarding/imported-wallet-account-creation.spec.ts +++ b/tests/performance/onboarding/imported-wallet-account-creation.spec.ts @@ -1,5 +1,6 @@ import { test } from '../../framework/fixture'; import { + Performance, PerformanceOnboarding, PerformanceAccountList, } from '../../tags.performance.js'; @@ -10,7 +11,7 @@ import TimerHelper from '../../framework/TimerHelper'; import { onboardingFlowImportSRPPlaywright } from '../../flows/wallet.flow'; /* Scenario 1: Imported wallet with 50+ accounts + account creation */ -test.describe(`${PerformanceOnboarding} ${PerformanceAccountList}`, () => { +test.describe(`${Performance} ${PerformanceOnboarding} ${PerformanceAccountList}`, () => { test.skip( 'Account creation with 50+ accounts, SRP 1 + SRP 2 + SRP 3', { tag: '@metamask-onboarding-team' }, diff --git a/tests/performance/onboarding/launch-times/cold-start-after-wallet-import.spec.ts b/tests/performance/onboarding/launch-times/cold-start-after-wallet-import.spec.ts index bc87a9bbbfd..c6bcb582327 100644 --- a/tests/performance/onboarding/launch-times/cold-start-after-wallet-import.spec.ts +++ b/tests/performance/onboarding/launch-times/cold-start-after-wallet-import.spec.ts @@ -1,5 +1,6 @@ import { test } from '../../../framework/fixture'; import { + Performance, PerformanceOnboarding, PerformanceLaunch, } from '../../../tags.performance.js'; @@ -14,7 +15,7 @@ import { import TimerHelper from '../../../framework/TimerHelper'; import WalletView from '../../../page-objects/wallet/WalletView'; -test.describe(`${PerformanceOnboarding} ${PerformanceLaunch}`, () => { +test.describe(`${Performance} ${PerformanceOnboarding} ${PerformanceLaunch}`, () => { test( 'Cold Start after importing a wallet', { tag: '@metamask-mobile-platform' }, diff --git a/tests/performance/onboarding/launch-times/cold-start-to-onboarding.spec.ts b/tests/performance/onboarding/launch-times/cold-start-to-onboarding.spec.ts index 11a741806d0..14f9a750018 100644 --- a/tests/performance/onboarding/launch-times/cold-start-to-onboarding.spec.ts +++ b/tests/performance/onboarding/launch-times/cold-start-to-onboarding.spec.ts @@ -1,6 +1,7 @@ import { test } from '../../../framework/fixture'; import TimerHelper from '../../../framework/TimerHelper.js'; import { + Performance, PerformanceOnboarding, PerformanceLaunch, } from '../../../tags.performance.js'; @@ -8,7 +9,7 @@ import PlaywrightAssertions from '../../../framework/PlaywrightAssertions'; import OnboardingView from '../../../page-objects/Onboarding/OnboardingView'; import { asPlaywrightElement } from '../../../framework/EncapsulatedElement'; -test.describe(`${PerformanceOnboarding} ${PerformanceLaunch}`, () => { +test.describe(`${Performance} ${PerformanceOnboarding} ${PerformanceLaunch}`, () => { test( 'Measure Cold Start To Onboarding Screen', { tag: '@metamask-mobile-platform' }, diff --git a/tests/performance/onboarding/new-wallet-account-creation.spec.ts b/tests/performance/onboarding/new-wallet-account-creation.spec.ts index c2181fe7718..1651c43953b 100644 --- a/tests/performance/onboarding/new-wallet-account-creation.spec.ts +++ b/tests/performance/onboarding/new-wallet-account-creation.spec.ts @@ -2,6 +2,8 @@ import { test } from '../../framework/fixture'; import TimerHelper from '../../framework/TimerHelper'; import { getPasswordForScenario } from '../../framework/utils/TestConstants.js'; import { + Performance, + System, PerformanceOnboarding, PerformanceAccountList, } from '../../tags.performance.js'; @@ -25,7 +27,7 @@ import PredictModalView from '../../page-objects/Predict/PredictModalView.js'; const testEnvironment = process.env.E2E_PERFORMANCE_BUILD_VARIANT || ''; /* Scenario 2: Account creation after fresh install */ -test.describe(`${PerformanceOnboarding} ${PerformanceAccountList}`, () => { +test.describe(`${Performance} ${System} ${PerformanceOnboarding} ${PerformanceAccountList}`, () => { test( 'Account creation after fresh install', { tag: '@metamask-onboarding-team' }, diff --git a/tests/performance/onboarding/seedless-apple-onboarding.spec.ts b/tests/performance/onboarding/seedless-apple-onboarding.spec.ts index 762e5dcd47f..7a884cb78aa 100644 --- a/tests/performance/onboarding/seedless-apple-onboarding.spec.ts +++ b/tests/performance/onboarding/seedless-apple-onboarding.spec.ts @@ -3,7 +3,11 @@ import TimerHelper from '../../framework/TimerHelper'; import { asPlaywrightElement, PlaywrightAssertions } from '../../framework'; import { getPasswordForScenario } from '../../framework/utils/TestConstants.js'; import { dismisspredictionsModalPlaywright } from '../../flows/wallet.flow'; -import { PerformanceOnboarding } from '../../tags.performance.js'; +import { + Performance, + System, + PerformanceOnboarding, +} from '../../tags.performance.js'; import OnboardingView from '../../page-objects/Onboarding/OnboardingView'; import OnboardingSheet from '../../page-objects/Onboarding/OnboardingSheet'; import SocialLoginView from '../../page-objects/Onboarding/SocialLoginView'; @@ -28,7 +32,7 @@ const waitForFirstSuccessful = async (promises: Promise[]): Promise => }); /* Seedless Onboarding: Apple Login */ -test.describe(PerformanceOnboarding, () => { +test.describe(`${Performance} ${System} ${PerformanceOnboarding}`, () => { test.setTimeout(240000); test( diff --git a/tests/performance/onboarding/seedless-google-onboarding.spec.ts b/tests/performance/onboarding/seedless-google-onboarding.spec.ts index 809471d5faf..18086dd9f4b 100644 --- a/tests/performance/onboarding/seedless-google-onboarding.spec.ts +++ b/tests/performance/onboarding/seedless-google-onboarding.spec.ts @@ -3,7 +3,11 @@ import TimerHelper from '../../framework/TimerHelper'; import { asPlaywrightElement, PlaywrightAssertions } from '../../framework'; import { getPasswordForScenario } from '../../framework/utils/TestConstants.js'; import { dismisspredictionsModalPlaywright } from '../../flows/wallet.flow'; -import { PerformanceOnboarding } from '../../tags.performance.js'; +import { + Performance, + System, + PerformanceOnboarding, +} from '../../tags.performance.js'; import OnboardingView from '../../page-objects/Onboarding/OnboardingView'; import OnboardingSheet from '../../page-objects/Onboarding/OnboardingSheet'; import SocialLoginView from '../../page-objects/Onboarding/SocialLoginView'; @@ -28,7 +32,7 @@ const waitForFirstSuccessful = async (promises: Promise[]): Promise => }); /* Seedless Onboarding: Google Login */ -test.describe(PerformanceOnboarding, () => { +test.describe(`${Performance} ${System} ${PerformanceOnboarding}`, () => { test.setTimeout(240000); test( diff --git a/tests/playwright.config.ts b/tests/playwright.config.ts index d69cbd58723..180221ba3d5 100644 --- a/tests/playwright.config.ts +++ b/tests/playwright.config.ts @@ -5,6 +5,7 @@ export default defineConfig({ testDir: './', fullyParallel: false, timeout: 7 * 60 * 1000, //7 minutes until we introduce fixtures + grep: /@Performance/, reporter: [ [ 'html', diff --git a/tests/playwright.system-emulator.config.ts b/tests/playwright.system-emulator.config.ts new file mode 100644 index 00000000000..0a07e60f663 --- /dev/null +++ b/tests/playwright.system-emulator.config.ts @@ -0,0 +1,127 @@ +import dotenv from 'dotenv'; +dotenv.config({ path: '.e2e.env' }); + +import { Platform, ProviderName } from './framework/types'; +import { defineConfig } from './framework/config'; + +// Activate system test mode — disables quality gates, Sentry, and perf reporting +process.env.SYSTEM_TEST_MODE = 'true'; + +// ---------- Default build paths (match Detox / standard build output) ---------- +const DEFAULT_ANDROID_APK = + 'android/app/build/outputs/apk/prod/debug/app-prod-debug.apk'; +const DEFAULT_IOS_APP = + 'ios/build/Build/Products/Release-iphonesimulator/MetaMask.app'; + +/** + * System test config for local emulators / simulators. + * + * Same test specs and SYSTEM_TEST_MODE as playwright.system.config.ts (BrowserStack), + * but runs on a local Android emulator or iOS simulator via Appium. + * + * All environment variables have sensible defaults so you can run system tests + * right after a standard debug build (`yarn build:android:main:e2e` / `yarn build:ios:main:e2e`). + * + * Environment variables (all optional — defaults shown): + * - ANDROID_APK_PATH — Path to APK for login tests (default: prod debug APK) + * - ANDROID_CLEAN_APK_PATH — Path to clean APK for onboarding (default: same as ANDROID_APK_PATH) + * - IOS_APP_PATH — Path to .app for login tests (default: Release-iphonesimulator/MetaMask.app) + * - IOS_CLEAN_APP_PATH — Path to clean .app for onboarding (default: same as IOS_APP_PATH) + * - ANDROID_AVD_NAME — AVD name (default: 'Pixel_5_Pro_API_34') + * - IOS_SIMULATOR_NAME — Simulator name (default: 'iPhone 15 Pro') + * + * Usage: + * yarn run-system-tests:android-login-emu + * ANDROID_APK_PATH=/path/to/app.apk yarn run-system-tests:android-login-emu + * yarn run-system-tests:ios-login-sim + */ +export default defineConfig({ + testDir: './', + fullyParallel: false, + timeout: 7 * 60 * 1000, + retries: 1, + grep: /@System/, + reporter: [ + [ + 'html', + { open: 'never', outputFolder: './test-reports/system-test-report' }, + ], + ['list'], + ], + use: { + trace: 'on-first-retry', + }, + + projects: [ + { + name: 'system-android-login-emu', + testMatch: '**/performance/login/**/*.spec.ts', + use: { + platform: Platform.ANDROID, + device: { + provider: ProviderName.EMULATOR, + name: process.env.ANDROID_AVD_NAME || 'Pixel_5_Pro_API_34', + }, + app: { + packageName: 'io.metamask', + launchableActivity: 'io.metamask.MainActivity', + buildPath: process.env.ANDROID_APK_PATH || DEFAULT_ANDROID_APK, + }, + }, + }, + { + name: 'system-android-onboarding-emu', + testMatch: '**/performance/onboarding/**/*.spec.ts', + testIgnore: '**/performance/onboarding/seedless-*.spec.ts', + use: { + platform: Platform.ANDROID, + device: { + provider: ProviderName.EMULATOR, + name: process.env.ANDROID_AVD_NAME || 'Pixel_5_Pro_API_34', + }, + app: { + packageName: 'io.metamask', + launchableActivity: 'io.metamask.MainActivity', + buildPath: + process.env.ANDROID_CLEAN_APK_PATH || + process.env.ANDROID_APK_PATH || + DEFAULT_ANDROID_APK, + }, + }, + }, + { + name: 'system-ios-login-sim', + testMatch: '**/performance/login/**/*.spec.ts', + use: { + platform: Platform.IOS, + device: { + provider: ProviderName.SIMULATOR, + name: process.env.IOS_SIMULATOR_NAME || 'iPhone 15 Pro', + }, + app: { + appId: 'io.metamask.MetaMask', + buildPath: process.env.IOS_APP_PATH || DEFAULT_IOS_APP, + }, + }, + }, + { + name: 'system-ios-onboarding-sim', + testMatch: '**/performance/onboarding/**/*.spec.ts', + testIgnore: '**/performance/onboarding/seedless-*.spec.ts', + use: { + platform: Platform.IOS, + device: { + provider: ProviderName.SIMULATOR, + name: process.env.IOS_SIMULATOR_NAME || 'iPhone 15 Pro', + }, + app: { + appId: 'io.metamask.MetaMask', + buildPath: + process.env.IOS_CLEAN_APP_PATH || + process.env.IOS_APP_PATH || + DEFAULT_IOS_APP, + }, + }, + }, + ], +}); diff --git a/tests/playwright.system.config.ts b/tests/playwright.system.config.ts index 9068705b705..55ae90be3fa 100644 --- a/tests/playwright.system.config.ts +++ b/tests/playwright.system.config.ts @@ -11,6 +11,7 @@ export default defineConfig({ fullyParallel: false, timeout: 7 * 60 * 1000, retries: 1, + grep: /@System/, reporter: [ [ 'html', diff --git a/tests/tags.performance.js b/tests/tags.performance.js index b6afd002229..11898a85efa 100644 --- a/tests/tags.performance.js +++ b/tests/tags.performance.js @@ -5,20 +5,36 @@ * Use these tags to categorize and filter performance tests. * * Usage in tests: - * import { PerformanceLogin, PerformanceSwaps } from '../../tags.js'; + * import { Performance, System, PerformanceLogin, PerformanceSwaps } from '../../tags.performance.js'; * - * test.describe(PerformanceLogin, () => { - * test('My login test', async ({ device }) => { ... }); - * }); + * // Both perf and system test (most common): + * test.describe(`${Performance} ${System} ${PerformanceLogin} ${PerformanceSwaps}`, () => { ... }); * - * Or with multiple tags: - * test.describe(`${PerformanceLogin} ${PerformanceSwaps}`, () => { ... }); + * // System-only test (functional verification, no perf measurement): + * test.describe(`${System} ${PerformanceLogin}`, () => { ... }); + * + * // Perf-only test (not run in system test suite): + * test.describe(`${Performance} ${PerformanceLaunch}`, () => { ... }); * * Running tests with tags: - * yarn playwright test --grep "@PerformanceLogin" + * yarn playwright test --grep "@Performance" # All performance tests + * yarn playwright test --grep "@System" # All system tests + * yarn playwright test --grep "@PerformanceLogin" # By area * yarn playwright test --grep "@PerformanceSwaps|@PerformanceOnboarding" */ +// ---------- Test type tags ---------- +// Runner-agnostic tags that control which config/runner picks up a test. +// Used in test.describe() names and filtered via --grep / config grep. + +/** Tag for performance tests (measured with TimerHelper, quality gates enforced). */ +export const Performance = '@Performance'; + +/** Tag for system tests (functional verification, no quality gates or metrics). */ +export const System = '@System'; + +// ---------- Area tags ---------- + export const PerformanceAccountList = '@PerformanceAccountList'; export const PerformanceOnboarding = '@PerformanceOnboarding'; export const PerformanceLogin = '@PerformanceLogin'; From 2e4b3d7c4fc7cc9d56581c36d8b26a8fae485a4b Mon Sep 17 00:00:00 2001 From: jeremytsng Date: Wed, 6 May 2026 19:04:43 +0800 Subject: [PATCH 8/9] fix: migrate Sei explorer from Seitrace to Seiscan (#29221) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ## **Description** Seitrace (`https://seitrace.com`), the current Sei Mainnet block explorer, is being decommissioned. This PR swaps every hardcoded reference to `seiscan.io` and adds migration **134** to rewrite existing users' persisted `NetworkController` state on upgrade. Hardcoded URL swaps: - `app/util/networks/customNetworks.tsx` — Sei Mainnet `blockExplorerUrl` - `tests/resources/networks.e2e.js` — e2e resource - `tests/api-mocking/mock-responses/tx-sentinel-networks-map.ts` — mock `explorer` Migration 134 rewrites `engine.backgroundState.NetworkController.networkConfigurationsByChainId['0x531'].blockExplorerUrls` from `seitrace.com` to `seiscan.io` for existing installs. It only touches entries still pointing at `seitrace.com` — a user who customized their Sei block explorer (e.g. to `seistream.app`) is left alone. The migration follows mobile's current pattern (sync arrow function with in-place `state` mutation), and is registered in `app/store/migrations/index.ts`. Cross-repo reference for this family of block-explorer-URL migrations is [`metamask-extension/app/scripts/migrations/197.ts`](https://github.com/MetaMask/metamask-extension/blob/main/app/scripts/migrations/197.ts). The `@metamask/controller-utils` bump is deliberately deferred until the sibling PR in `MetaMask/core` releases; this PR stands alone. ## **Changelog** CHANGELOG entry: Fixed Sei Mainnet: replaced deprecated Seitrace explorer with Seiscan (`https://seiscan.io`). Existing installs are migrated via migration 134. ## **Related issues** Fixes: Companion PRs: - [`MetaMask/core#8545`](https://github.com/MetaMask/core/pull/8545) — default `BlockExplorerUrl[SeiMainnet]` - [`MetaMask/metafi-sdk#525`](https://github.com/MetaMask/metafi-sdk/pull/525) — shared SDK `SEI_EXPLORER` - [`MetaMask/metamask-extension#42064`](https://github.com/MetaMask/metamask-extension/pull/42064) — extension migration 207 ## **Manual testing steps** ```gherkin Feature: Sei Mainnet block explorer URL Scenario: fresh install uses Seiscan Given a fresh install of MetaMask Mobile When the user adds the Sei Mainnet network Then the network's block-explorer URL is "https://seiscan.io/" And tapping a Sei transaction's "View on block explorer" opens "https://seiscan.io/tx/" Scenario: existing user with Seitrace URL is migrated Given a build prior to this change with Sei Mainnet added And the stored "blockExplorerUrls" is ["https://seitrace.com"] When the user upgrades to this build Then migration 134 runs And the stored "blockExplorerUrls" for Sei Mainnet is ["https://seiscan.io"] Scenario: user-customized URL is preserved Given a build prior to this change with Sei Mainnet added And the user has customized "blockExplorerUrls" to ["https://seistream.app"] When the user upgrades to this build Then migration 134 is a no-op for this entry And the stored "blockExplorerUrls" for Sei Mainnet remains ["https://seistream.app"] ``` ## **Screenshots/Recordings** ### **Before** ### **After** ## **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 - [ ] 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) - [ ] I've tested on Android - [ ] I've tested with a power user scenario - [ ] I've instrumented key operations with Sentry traces for production performance metrics ## **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** > Adds a new persisted-state migration that mutates `NetworkController` network configs for Sei Mainnet; while narrowly scoped, migrations run on upgrade and can affect existing user state if bugs slip through. > > **Overview** > Updates Sei Mainnet’s default block explorer from **Seitrace** to **Seiscan** across the in-app popular network config and test fixtures. > > Adds migration `134` (registered in `app/store/migrations/index.ts`) to rewrite persisted Sei Mainnet `blockExplorerUrls` entries whose URL hostname is exactly `seitrace.com` to `seiscan.io`, while leaving missing/invalid controller state and user-customized/lookalike URLs untouched; includes unit tests covering the rewrite and no-op cases. > > Reviewed by [Cursor Bugbot](https://cursor.com/bugbot) for commit 68e91bcb0b954a7d2617a519855177dd34d62fa0. Bugbot is set up for automated code reviews on this repo. Configure [here](https://www.cursor.com/dashboard/bugbot). --- app/store/migrations/134.test.ts | 168 +++++++++++++++++ app/store/migrations/134.ts | 176 ++++++++++++++++++ app/store/migrations/index.ts | 2 + app/util/networks/customNetworks.tsx | 2 +- .../tx-sentinel-networks-map.ts | 2 +- tests/resources/networks.e2e.js | 2 +- 6 files changed, 349 insertions(+), 3 deletions(-) create mode 100644 app/store/migrations/134.test.ts create mode 100644 app/store/migrations/134.ts diff --git a/app/store/migrations/134.test.ts b/app/store/migrations/134.test.ts new file mode 100644 index 00000000000..e485c9ca363 --- /dev/null +++ b/app/store/migrations/134.test.ts @@ -0,0 +1,168 @@ +import { captureException } from '@sentry/react-native'; +import migrate, { migrationVersion } from './134'; +import { ensureValidState } from './util'; + +jest.mock('@sentry/react-native', () => ({ + captureException: jest.fn(), +})); + +jest.mock('./util', () => ({ + ensureValidState: jest.fn(), +})); + +const mockedEnsureValidState = jest.mocked(ensureValidState); +const mockedCaptureException = jest.mocked(captureException); + +const SEI_MAINNET_CHAIN_ID = '0x531'; + +interface SeiNetworkConfiguration { + blockExplorerUrls: string[]; + chainId: string; + defaultBlockExplorerUrlIndex?: number; + defaultRpcEndpointIndex: number; + name: string; + nativeCurrency: string; + rpcEndpoints: { + networkClientId: string; + url: string; + type: string; + failoverUrls?: string[]; + }[]; +} + +interface TestState { + engine: { + backgroundState: { + NetworkController?: { + networkConfigurationsByChainId?: Record< + string, + SeiNetworkConfiguration + >; + selectedNetworkClientId?: string; + [key: string]: unknown; + }; + [key: string]: unknown; + }; + }; + [key: string]: unknown; +} + +function buildSeiConfig(blockExplorerUrls: string[]): SeiNetworkConfiguration { + return { + blockExplorerUrls, + chainId: SEI_MAINNET_CHAIN_ID, + defaultBlockExplorerUrlIndex: 0, + defaultRpcEndpointIndex: 0, + name: 'Sei', + nativeCurrency: 'SEI', + rpcEndpoints: [ + { + networkClientId: 'sei-mainnet', + url: 'https://sei-mainnet.infura.io/v3/fake', + type: 'custom', + }, + ], + }; +} + +function buildState(seiConfig?: SeiNetworkConfiguration): TestState { + return { + engine: { + backgroundState: { + NetworkController: { + networkConfigurationsByChainId: seiConfig + ? { [SEI_MAINNET_CHAIN_ID]: seiConfig } + : {}, + selectedNetworkClientId: 'mainnet', + }, + }, + }, + }; +} + +describe(`Migration ${migrationVersion}: Replace Seitrace with Seiscan for Sei Mainnet`, () => { + beforeEach(() => { + jest.clearAllMocks(); + mockedEnsureValidState.mockReturnValue(true); + }); + + it('reports the expected migration version', () => { + expect(migrationVersion).toBe(134); + }); + + it('rewrites Seitrace block explorer URL to Seiscan for Sei Mainnet', () => { + const state = buildState(buildSeiConfig(['https://seitrace.com'])); + + const result = migrate(state) as TestState; + + const seiConfig = + result.engine.backgroundState.NetworkController + ?.networkConfigurationsByChainId?.[SEI_MAINNET_CHAIN_ID]; + expect(seiConfig?.blockExplorerUrls).toStrictEqual(['https://seiscan.io/']); + expect(mockedCaptureException).not.toHaveBeenCalled(); + }); + + it('leaves state unchanged when Sei Mainnet is not configured', () => { + const state: TestState = { + engine: { + backgroundState: { + NetworkController: { + networkConfigurationsByChainId: { + '0x1': buildSeiConfig(['https://etherscan.io']), + }, + selectedNetworkClientId: 'mainnet', + }, + }, + }, + }; + const snapshotBefore = JSON.stringify(state); + + const result = migrate(state); + + expect(JSON.stringify(result)).toBe(snapshotBefore); + expect(mockedCaptureException).not.toHaveBeenCalled(); + }); + + it('silently skips when NetworkController is missing (upgrade-from-old-version)', () => { + const state = { + engine: { + backgroundState: { + SomeOtherController: { foo: 'bar' }, + }, + }, + }; + const snapshotBefore = JSON.stringify(state); + + const result = migrate(state); + + expect(JSON.stringify(result)).toBe(snapshotBefore); + expect(mockedCaptureException).not.toHaveBeenCalled(); + }); + + it('does not touch user-customized block explorer URLs', () => { + const state = buildState(buildSeiConfig(['https://seistream.app'])); + + const result = migrate(state) as TestState; + + const seiConfig = + result.engine.backgroundState.NetworkController + ?.networkConfigurationsByChainId?.[SEI_MAINNET_CHAIN_ID]; + expect(seiConfig?.blockExplorerUrls).toStrictEqual([ + 'https://seistream.app', + ]); + expect(mockedCaptureException).not.toHaveBeenCalled(); + }); + + it('does not rewrite a URL whose host only starts with seitrace.com', () => { + const lookalike = 'https://seitrace.com.attacker.example/path'; + const state = buildState(buildSeiConfig([lookalike])); + + const result = migrate(state) as TestState; + + const seiConfig = + result.engine.backgroundState.NetworkController + ?.networkConfigurationsByChainId?.[SEI_MAINNET_CHAIN_ID]; + expect(seiConfig?.blockExplorerUrls).toStrictEqual([lookalike]); + expect(mockedCaptureException).not.toHaveBeenCalled(); + }); +}); diff --git a/app/store/migrations/134.ts b/app/store/migrations/134.ts new file mode 100644 index 00000000000..c332a0e1b1b --- /dev/null +++ b/app/store/migrations/134.ts @@ -0,0 +1,176 @@ +import { captureException } from '@sentry/react-native'; +import { + getErrorMessage, + hasProperty, + Hex, + isHexString, + isObject, +} from '@metamask/utils'; + +import { ensureValidState } from './util'; + +/** + * Migration 134: replace the deprecated Seitrace block explorer URL + * (`seitrace.com`, being decommissioned) with its replacement Seiscan + * (`seiscan.io`) for Sei Mainnet on existing user installs. + * + * Users without Sei Mainnet configured: no-op (silent). + * Users who customized the explorer URL away from Seitrace: no-op + * (only entries that still point at `seitrace.com` are rewritten). + * Users missing `NetworkController` entirely: no-op (silent) — expected + * during upgrade-from-old-version. + */ +export const migrationVersion = 134; + +const SEI_MAINNET_CHAIN_ID: Hex = '0x531'; // 1329 +const OLD_HOSTNAME = 'seitrace.com'; +const NEW_HOSTNAME = 'seiscan.io'; + +interface RpcEndpoint { + failoverUrls?: string[]; + name?: string; + networkClientId: string; + url: string; + type: string; +} + +interface NetworkConfiguration { + blockExplorerUrls: string[]; + chainId: Hex; + defaultBlockExplorerUrlIndex?: number; + defaultRpcEndpointIndex: number; + name: string; + nativeCurrency: string; + rpcEndpoints: RpcEndpoint[]; +} + +const migration = (state: unknown): unknown => { + if (!ensureValidState(state, migrationVersion)) { + return state; + } + + try { + const networkControllerState = validateNetworkController(state); + if (networkControllerState === undefined) { + return state; + } + + const { networkConfigurationsByChainId } = networkControllerState; + if (!hasProperty(networkConfigurationsByChainId, SEI_MAINNET_CHAIN_ID)) { + return state; + } + + const seiConfig = networkConfigurationsByChainId[SEI_MAINNET_CHAIN_ID]; + if (!isValidNetworkConfiguration(seiConfig)) { + return state; + } + + const rewritten = seiConfig.blockExplorerUrls.map((url) => { + try { + const parsed = new URL(url); + if (parsed.hostname === OLD_HOSTNAME) { + parsed.hostname = NEW_HOSTNAME; + return parsed.toString(); + } + } catch { + // not a valid URL, leave as-is + } + return url; + }); + const didChange = rewritten.some( + (url, index) => url !== seiConfig.blockExplorerUrls[index], + ); + + if (didChange) { + seiConfig.blockExplorerUrls = rewritten; + } + } catch (error) { + captureException( + new Error( + `Migration ${migrationVersion}: Failed to rewrite Sei Mainnet block explorer URL: ${getErrorMessage( + error, + )}`, + ), + ); + } + + return state; +}; + +export default migration; + +// Sentry logging is intentionally omitted — expected-missing states +// (NetworkController absent, Sei not configured) are not errors. +function validateNetworkController(state: { + engine: { backgroundState: Record }; +}): + | { + networkConfigurationsByChainId: Record; + selectedNetworkClientId: string; + } + | undefined { + if (!hasProperty(state.engine.backgroundState, 'NetworkController')) { + // Expected during upgrade-from-old-version — don't log. + return undefined; + } + + const networkControllerState = state.engine.backgroundState.NetworkController; + + if (!isValidNetworkControllerState(networkControllerState)) { + return undefined; + } + + return networkControllerState; +} + +function isValidNetworkControllerState(value: unknown): value is { + networkConfigurationsByChainId: Record; + selectedNetworkClientId: string; +} { + if (!isObject(value)) { + return false; + } + + if ( + !hasProperty(value, 'networkConfigurationsByChainId') || + !isValidNetworkConfigurationsByChainId(value.networkConfigurationsByChainId) + ) { + return false; + } + + if ( + !hasProperty(value, 'selectedNetworkClientId') || + typeof value.selectedNetworkClientId !== 'string' + ) { + return false; + } + + return true; +} + +function isValidNetworkConfigurationsByChainId( + value: unknown, +): value is Record { + return ( + isObject(value) && + Object.entries(value).every( + ([chainId]) => typeof chainId === 'string' && isHexString(chainId), + ) + ); +} + +// Minimal validator — only chainId + blockExplorerUrls need to be sound +// for this migration. +function isValidNetworkConfiguration( + object: unknown, +): object is NetworkConfiguration { + return ( + isObject(object) && + hasProperty(object, 'chainId') && + typeof object.chainId === 'string' && + isHexString(object.chainId) && + hasProperty(object, 'blockExplorerUrls') && + Array.isArray(object.blockExplorerUrls) && + object.blockExplorerUrls.every((url) => typeof url === 'string') + ); +} diff --git a/app/store/migrations/index.ts b/app/store/migrations/index.ts index 43daf523d7a..947dbb5bdec 100644 --- a/app/store/migrations/index.ts +++ b/app/store/migrations/index.ts @@ -134,6 +134,7 @@ import migration130 from './130'; import migration131 from './131'; import migration132 from './132'; import migration133 from './133'; +import migration134 from './134'; // Add migrations above this line import { ControllerStorage } from '../persistConfig'; @@ -287,6 +288,7 @@ export const migrationList: MigrationsList = { 131: migration131, 132: migration132, 133: migration133, + 134: migration134, }; // Enable both synchronous and asynchronous migrations diff --git a/app/util/networks/customNetworks.tsx b/app/util/networks/customNetworks.tsx index a53aaf86221..7c2aaa208d2 100644 --- a/app/util/networks/customNetworks.tsx +++ b/app/util/networks/customNetworks.tsx @@ -159,7 +159,7 @@ export const PopularList = [ ticker: 'SEI', warning: true, rpcPrefs: { - blockExplorerUrl: 'https://seitrace.com/', + blockExplorerUrl: 'https://seiscan.io/', imageUrl: 'SEI', imageSource: require('../../images/sei.png'), }, diff --git a/tests/api-mocking/mock-responses/tx-sentinel-networks-map.ts b/tests/api-mocking/mock-responses/tx-sentinel-networks-map.ts index 0a28e014b13..ce1085fd0dd 100644 --- a/tests/api-mocking/mock-responses/tx-sentinel-networks-map.ts +++ b/tests/api-mocking/mock-responses/tx-sentinel-networks-map.ts @@ -64,7 +64,7 @@ export const TX_SENTINEL_NETWORKS_MAP = { decimals: 18, }, network: 'sei-mainnet', - explorer: 'https://seitrace.com', + explorer: 'https://seiscan.io', confirmations: true, smartTransactions: false, relayTransactions: false, diff --git a/tests/resources/networks.e2e.js b/tests/resources/networks.e2e.js index 745b0f0c691..3994bec42d3 100644 --- a/tests/resources/networks.e2e.js +++ b/tests/resources/networks.e2e.js @@ -192,7 +192,7 @@ const CustomNetworks = { rpcUrl: 'https://sei-mainnet.infura.io', nickname: 'Sei Testnet', ticker: 'SEI', - BlockExplorerUrl: 'https://seitrace.com/', + BlockExplorerUrl: 'https://seiscan.io/', }, }, }; From 9432aa09a7a103014fccd29a1daaed9bc0d0f7ac Mon Sep 17 00:00:00 2001 From: Alexey Kureev Date: Wed, 6 May 2026 13:10:25 +0200 Subject: [PATCH 9/9] fix(MUSD-701): use Money Account balance in Add money sheet (#29734) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ## **Description** The "Move mUSD" row in the Add money sheet was showing a balance that did not match the Money Account balance displayed at the top of the screen. The two figures were sourced from different places, which surfaced as an obvious mismatch whenever the selected EVM account was not the same as the Money Account. Root cause: the sheet was reading from `useMusdBalance().fiatBalanceAggregatedFormatted`, which aggregates mUSD across the currently selected EVM account. The screen header, by contrast, uses `useMoneyAccountBalance`, which is the canonical Money Account balance. Fix: switch the sheet to consume `useMoneyAccountBalance().totalFiatFormatted` so the "Move mUSD" amount and the header always agree. Tests were updated to mock the new hook and assert the same fallback behaviour (no-amount copy when the balance is unavailable, locale fiat prefix preserved). Credit to Matthew Grainger for diagnosing the source-of-truth mismatch on the Jira ticket. ## **Changelog** CHANGELOG entry: null ## **Related issues** Fixes: MUSD-701 ## **Manual testing steps** ```gherkin Feature: Add money sheet balance consistency Scenario: user opens the Add money sheet with a funded Money Account Given the user has a non-zero Money Account balance And the selected EVM account is not the Money Account When the user opens the Add money sheet Then the "Move mUSD" row shows the same fiat amount as the Money Account header Scenario: user opens the Add money sheet with no mUSD Given the user's Money Account balance is unavailable or zero When the user opens the Add money sheet Then the "Move mUSD" row shows the no-amount copy ``` ## **Screenshots/Recordings** ### **Before** ### **After** ## **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 - [ ] I've documented my code using [JSDoc](https://jsdoc.app/) format if applicable - [ ] 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) - [ ] I've tested on Android - [ ] I've tested with a power user scenario - [ ] 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** > Low risk UI data-source swap: the “Move mUSD” row now reads from the canonical Money Account balance, which could only affect displayed copy if the previous EVM-account-aggregated value differed or was undefined. > > **Overview** > Updates the Add Money bottom sheet so the “Move mUSD” option displays the **Money Account** fiat balance (via `useMoneyAccountBalance().totalFiatFormatted`) instead of the previously EVM-account-aggregated `useMusdBalance()` value, eliminating mismatches with the header balance. > > Adjusts `MoneyAddMoneySheet` tests to mock the new hook and keep the same behaviors for locale-prefixed amounts and the no-amount fallback copy. > > Reviewed by [Cursor Bugbot](https://cursor.com/bugbot) for commit 88dd0bcaef1ad5e246c570f95c359900dcb23889. Bugbot is set up for automated code reviews on this repo. Configure [here](https://www.cursor.com/dashboard/bugbot). --- .../MoneyAddMoneySheet.test.tsx | 19 ++++++++++--------- .../MoneyAddMoneySheet/MoneyAddMoneySheet.tsx | 8 ++++---- 2 files changed, 14 insertions(+), 13 deletions(-) diff --git a/app/components/UI/Money/components/MoneyAddMoneySheet/MoneyAddMoneySheet.test.tsx b/app/components/UI/Money/components/MoneyAddMoneySheet/MoneyAddMoneySheet.test.tsx index f669718c1b1..e0018003b4b 100644 --- a/app/components/UI/Money/components/MoneyAddMoneySheet/MoneyAddMoneySheet.test.tsx +++ b/app/components/UI/Money/components/MoneyAddMoneySheet/MoneyAddMoneySheet.test.tsx @@ -5,7 +5,7 @@ import MoneyAddMoneySheet from './MoneyAddMoneySheet'; import { MoneyAddMoneySheetTestIds } from './MoneyAddMoneySheet.testIds'; import { useMusdConversionFlowData } from '../../../Earn/hooks/useMusdConversionFlowData'; import { useRampNavigation } from '../../../Ramp/hooks/useRampNavigation'; -import { useMusdBalance } from '../../../Earn/hooks/useMusdBalance'; +import useMoneyAccountBalance from '../../hooks/useMoneyAccountBalance'; import { useMoneyAccountDeposit } from '../../hooks/useMoneyAccount'; import { MUSD_CONVERSION_DEFAULT_CHAIN_ID, @@ -38,8 +38,9 @@ jest.mock('../../../Ramp/hooks/useRampNavigation', () => ({ useRampNavigation: jest.fn(), })); -jest.mock('../../../Earn/hooks/useMusdBalance', () => ({ - useMusdBalance: jest.fn(), +jest.mock('../../hooks/useMoneyAccountBalance', () => ({ + __esModule: true, + default: jest.fn(), })); jest.mock('../../hooks/useMoneyAccount', () => ({ @@ -86,8 +87,8 @@ describe('MoneyAddMoneySheet', () => { (useRampNavigation as jest.Mock).mockReturnValue({ goToBuy: mockGoToBuy, }); - (useMusdBalance as jest.Mock).mockReturnValue({ - fiatBalanceAggregatedFormatted: '$1,203.89', + (useMoneyAccountBalance as jest.Mock).mockReturnValue({ + totalFiatFormatted: '$1,203.89', }); (useMoneyAccountDeposit as jest.Mock).mockReturnValue({ initiateDeposit: mockInitiateDeposit, @@ -110,8 +111,8 @@ describe('MoneyAddMoneySheet', () => { }); it('preserves the locale fiat prefix in the Move mUSD row', () => { - (useMusdBalance as jest.Mock).mockReturnValue({ - fiatBalanceAggregatedFormatted: 'CA$1,500.00', + (useMoneyAccountBalance as jest.Mock).mockReturnValue({ + totalFiatFormatted: 'CA$1,500.00', }); const { getByText } = renderWithProvider(); @@ -119,8 +120,8 @@ describe('MoneyAddMoneySheet', () => { }); it('falls back to the no-amount copy when the mUSD balance is unavailable', () => { - (useMusdBalance as jest.Mock).mockReturnValue({ - fiatBalanceAggregatedFormatted: undefined, + (useMoneyAccountBalance as jest.Mock).mockReturnValue({ + totalFiatFormatted: undefined, }); const { getByText } = renderWithProvider(); diff --git a/app/components/UI/Money/components/MoneyAddMoneySheet/MoneyAddMoneySheet.tsx b/app/components/UI/Money/components/MoneyAddMoneySheet/MoneyAddMoneySheet.tsx index 6b559e9982d..c62a4bbda98 100644 --- a/app/components/UI/Money/components/MoneyAddMoneySheet/MoneyAddMoneySheet.tsx +++ b/app/components/UI/Money/components/MoneyAddMoneySheet/MoneyAddMoneySheet.tsx @@ -17,8 +17,8 @@ import { import Tag from '../../../../../component-library/components/Tags/Tag'; import { strings } from '../../../../../../locales/i18n'; import { useStyles } from '../../../../../component-library/hooks'; -import { useMusdBalance } from '../../../Earn/hooks/useMusdBalance'; import { useMusdConversionFlowData } from '../../../Earn/hooks/useMusdConversionFlowData'; +import useMoneyAccountBalance from '../../hooks/useMoneyAccountBalance'; import { MUSD_CONVERSION_DEFAULT_CHAIN_ID, MUSD_TOKEN_ASSET_ID_BY_CHAIN, @@ -40,7 +40,7 @@ const MoneyAddMoneySheet: React.FC = () => { const navigation = useNavigation(); const { styles } = useStyles(styleSheet, {}); - const { fiatBalanceAggregatedFormatted } = useMusdBalance(); + const { totalFiatFormatted } = useMoneyAccountBalance(); const { getChainIdForBuyFlow } = useMusdConversionFlowData(); const { goToBuy } = useRampNavigation(); const { initiateDeposit } = useMoneyAccountDeposit(); @@ -94,9 +94,9 @@ const MoneyAddMoneySheet: React.FC = () => { testID: MoneyAddMoneySheetTestIds.DEPOSIT_FUNDS_OPTION, }, { - label: fiatBalanceAggregatedFormatted + label: totalFiatFormatted ? strings('money.add_money_sheet.move_musd', { - amount: fiatBalanceAggregatedFormatted, + amount: totalFiatFormatted, }) : strings('money.add_money_sheet.move_musd_no_amount'), icon: IconName.Add,