From 0beac7bdf85751a14769ba5a3006bc260d027a99 Mon Sep 17 00:00:00 2001
From: ieow <4881057+ieow@users.noreply.github.com>
Date: Thu, 7 May 2026 13:35:17 +0800
Subject: [PATCH 01/13] feat: update client id (#29776)
## **Description**
OAuth client IDs for Google and Apple sign-in (`IOS_GOOGLE_CLIENT_ID`,
`IOS_GOOGLE_REDIRECT_URI`, `ANDROID_GOOGLE_CLIENT_ID`,
`ANDROID_GOOGLE_SERVER_CLIENT_ID`, `ANDROID_APPLE_CLIENT_ID`) were
previously sourced from `process.env`, requiring manual environment
variable configuration and risking misconfiguration across different
build types.
This PR moves those client IDs into the existing `OAUTH_CONFIG` object
in `config.ts`, keyed by build type (development, main_prod, main_uat,
main_dev, flask_prod, flask_uat, flask_dev). The constants in
`constants.ts` now read from `CURRENT_OAUTH_CONFIG` instead of
`process.env`, ensuring the correct client IDs are automatically
selected based on the build type. The corresponding environment variable
entries have been removed from `.js.env.example`.
## **Changelog**
CHANGELOG entry: null
## **Related issues**
Fixes:
## **Manual testing steps**
```gherkin
Feature: Seedless onboarding OAuth login
Scenario: user signs in with Google on iOS
Given the app is built with a main production build type
When user taps "Sign in with Google" during onboarding
Then the Google OAuth flow uses the correct production client ID
And the user is authenticated successfully
Scenario: user signs in with Google on Android
Given the app is built with a main production build type
When user taps "Sign in with Google" during onboarding
Then the Google OAuth flow uses the correct production server client ID
And the user is authenticated successfully
Scenario: user signs in with Apple on Android
Given the app is built with a main production build type
When user taps "Sign in with Apple" during onboarding
Then the Apple OAuth flow uses the correct production Apple client ID
And the user is authenticated successfully
```
## **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]
> **Medium Risk**
> Touches OAuth configuration used for Google/Apple login; incorrect
client IDs/redirect URIs could break authentication in specific build
targets despite being mostly a config refactor.
>
> **Overview**
> OAuth client IDs/redirect URIs are now **defined per build type** in
`OAuthLoginHandlers/config.ts` and consumed via `CURRENT_OAUTH_CONFIG`
in `constants.ts`, instead of being read from `process.env`.
>
> The example env file removes the seedless-onboarding client ID
entries, and unit tests are updated to assert against
`OAUTH_CONFIG.main_prod` values for Android and legacy iOS Google config
selection.
>
> Reviewed by [Cursor Bugbot](https://cursor.com/bugbot) for commit
fd46114bde615157ccb5c05dccc978ea1130af83. Bugbot is set up for automated
code reviews on this repo. Configure
[here](https://www.cursor.com/dashboard/bugbot).
---
.js.env.example | 14 ----
.../OAuthService/OAuthLoginHandlers/config.ts | 76 +++++++++++++++++++
.../OAuthLoginHandlers/constants.test.ts | 34 +++++----
.../OAuthLoginHandlers/constants.ts | 10 ++-
4 files changed, 103 insertions(+), 31 deletions(-)
diff --git a/.js.env.example b/.js.env.example
index 5cfe0c32564..67940dfec48 100644
--- a/.js.env.example
+++ b/.js.env.example
@@ -154,20 +154,6 @@ export E2E_MOCK_OAUTH='false'
export E2E_BYOA_AUTH_SECRET=''
export E2E_MOCK_OAUTH_EMAIL=''
-# env for seedless onboarding main-dev
-export ANDROID_APPLE_CLIENT_ID='io.metamask.appleloginclient.dev'
-export ANDROID_GOOGLE_CLIENT_ID='8615965109465-i8oeh9kuvl1n6lk1ffkobpvth27bmi41.apps.googleusercontent.com'
-export ANDROID_GOOGLE_SERVER_CLIENT_ID='615965109465-i8oeh9kuvl1n6lk1ffkobpvth27bmi41.apps.googleusercontent.com'
-export IOS_GOOGLE_CLIENT_ID='615965109465-h6tp2h3crls6hbggispcgovbvk4vabu3.apps.googleusercontent.com'
-export IOS_GOOGLE_REDIRECT_URI='com.googleusercontent.apps.615965109465-h6tp2h3crls6hbggispcgovbvk4vabu3:/oauth2redirect/google'
-
-# env for seedless onboarding flask-dev
-#export ANDROID_APPLE_CLIENT_ID="io.metamask.appleloginclient.flask.dev"
-#export ANDROID_GOOGLE_CLIENT_ID="615965109465-ab20kuqbls6fj5s50fvmvbnket8nv1sh.apps.googleusercontent.com"
-#export ANDROID_GOOGLE_SERVER_CLIENT_ID="615965109465-ab20kuqbls6fj5s50fvmvbnket8nv1sh.apps.googleusercontent.com"
-#export IOS_GOOGLE_CLIENT_ID="615965109465-89b2lmqgm5ka8j8t403qhooouv57id9b.apps.googleusercontent.com"
-#export IOS_GOOGLE_REDIRECT_URI="com.googleusercontent.apps.615965109465-89b2lmqgm5ka8j8t403qhooouv57id9b:/oauth2redirect/google"
-
# Enable send re-designs locally
export MM_SEND_REDESIGN_ENABLED="true"
diff --git a/app/core/OAuthService/OAuthLoginHandlers/config.ts b/app/core/OAuthService/OAuthLoginHandlers/config.ts
index 60e96839a6b..3be9292964e 100644
--- a/app/core/OAuthService/OAuthLoginHandlers/config.ts
+++ b/app/core/OAuthService/OAuthLoginHandlers/config.ts
@@ -1,4 +1,10 @@
interface OAUTH_CONFIG_TYPE {
+ IOS_GOOGLE_CLIENT_ID: string;
+ IOS_GOOGLE_REDIRECT_URI: string;
+ ANDROID_GOOGLE_CLIENT_ID: string;
+ ANDROID_GOOGLE_SERVER_CLIENT_ID: string;
+ ANDROID_APPLE_CLIENT_ID: string;
+
AUTH_SERVER_URL: string;
WEB3AUTH_NETWORK: string;
@@ -22,6 +28,16 @@ enum BUILD_TYPE {
export const OAUTH_CONFIG: Record = {
development: {
+ IOS_GOOGLE_CLIENT_ID:
+ '615965109465-h6tp2h3crls6hbggispcgovbvk4vabu3.apps.googleusercontent.com',
+ IOS_GOOGLE_REDIRECT_URI:
+ 'com.googleusercontent.apps.615965109465-h6tp2h3crls6hbggispcgovbvk4vabu3:/oauth2redirect/google',
+ ANDROID_GOOGLE_CLIENT_ID:
+ '615965109465-laapla9g0klg2p7rp5mn66fcl6jc4fes.apps.googleusercontent.com',
+ ANDROID_GOOGLE_SERVER_CLIENT_ID:
+ '615965109465-i8oeh9kuvl1n6lk1ffkobpvth27bmi41.apps.googleusercontent.com',
+ ANDROID_APPLE_CLIENT_ID: 'io.metamask.appleloginclient.dev',
+
GOOGLE_GROUPED_AUTH_CONNECTION_ID: 'mm-seedless-onboarding',
APPLE_GROUPED_AUTH_CONNECTION_ID: 'mm-seedless-onboarding',
AUTH_SERVER_URL: 'https://api-develop-torus-byoa.web3auth.io',
@@ -33,6 +49,16 @@ export const OAUTH_CONFIG: Record = {
IOS_APPLE_AUTH_CONNECTION_ID: 'byoa-server',
},
main_prod: {
+ IOS_GOOGLE_CLIENT_ID:
+ '795351133007-47aohp9j9n7r8fef5n6ejeauhu4kfc9e.apps.googleusercontent.com',
+ IOS_GOOGLE_REDIRECT_URI:
+ 'com.googleusercontent.apps.795351133007-47aohp9j9n7r8fef5n6ejeauhu4kfc9e:/oauth2redirect/google',
+ ANDROID_GOOGLE_CLIENT_ID:
+ '795351133007-jcaor637tblrlpuj29shdej3co8bu8kv.apps.googleusercontent.com',
+ ANDROID_GOOGLE_SERVER_CLIENT_ID:
+ '795351133007-6d0s31utj13knv440fgjo2ur93241gb6.apps.googleusercontent.com',
+ ANDROID_APPLE_CLIENT_ID: 'io.metamask.appleloginclient.prod',
+
GOOGLE_GROUPED_AUTH_CONNECTION_ID: 'mm-google-main',
APPLE_GROUPED_AUTH_CONNECTION_ID: 'mm-apple-main',
AUTH_SERVER_URL: 'https://auth-service.api.cx.metamask.io',
@@ -44,6 +70,16 @@ export const OAUTH_CONFIG: Record = {
IOS_APPLE_AUTH_CONNECTION_ID: 'mm-apple-main-common',
},
main_uat: {
+ IOS_GOOGLE_CLIENT_ID:
+ '387141446914-5ja3p4dfanfkm8uq238fm1b8t1rkscv4.apps.googleusercontent.com',
+ IOS_GOOGLE_REDIRECT_URI:
+ 'com.googleusercontent.apps.387141446914-5ja3p4dfanfkm8uq238fm1b8t1rkscv4:/oauth2redirect/google',
+ ANDROID_GOOGLE_CLIENT_ID:
+ '387141446914-7rl5s9s1uv82fgb03f93eqc0n8jq7t6k.apps.googleusercontent.com',
+ ANDROID_GOOGLE_SERVER_CLIENT_ID:
+ '387141446914-olajr83p1bbvabh1u8tfglt1k4u6jlcb.apps.googleusercontent.com',
+ ANDROID_APPLE_CLIENT_ID: 'io.metamask.appleloginclient.uat',
+
GOOGLE_GROUPED_AUTH_CONNECTION_ID: 'mm-google-uat',
APPLE_GROUPED_AUTH_CONNECTION_ID: 'mm-apple-uat',
AUTH_SERVER_URL: 'https://auth-service.uat-api.cx.metamask.io',
@@ -55,6 +91,16 @@ export const OAUTH_CONFIG: Record = {
IOS_APPLE_AUTH_CONNECTION_ID: 'mm-apple-uat-common',
},
main_dev: {
+ IOS_GOOGLE_CLIENT_ID:
+ '615965109465-h6tp2h3crls6hbggispcgovbvk4vabu3.apps.googleusercontent.com',
+ IOS_GOOGLE_REDIRECT_URI:
+ 'com.googleusercontent.apps.615965109465-h6tp2h3crls6hbggispcgovbvk4vabu3:/oauth2redirect/google',
+ ANDROID_GOOGLE_CLIENT_ID:
+ '615965109465-laapla9g0klg2p7rp5mn66fcl6jc4fes.apps.googleusercontent.com',
+ ANDROID_GOOGLE_SERVER_CLIENT_ID:
+ '615965109465-i8oeh9kuvl1n6lk1ffkobpvth27bmi41.apps.googleusercontent.com',
+ ANDROID_APPLE_CLIENT_ID: 'io.metamask.appleloginclient.dev',
+
GOOGLE_GROUPED_AUTH_CONNECTION_ID: 'mm-google-dev',
APPLE_GROUPED_AUTH_CONNECTION_ID: 'mm-apple-dev',
AUTH_SERVER_URL: 'https://auth-service.dev-api.cx.metamask.io',
@@ -66,6 +112,16 @@ export const OAUTH_CONFIG: Record = {
IOS_APPLE_AUTH_CONNECTION_ID: 'mm-apple-dev-common',
},
flask_prod: {
+ IOS_GOOGLE_CLIENT_ID:
+ '795351133007-gvuagr9t7tfkak3sp2cmng4pdhchlfpd.apps.googleusercontent.com',
+ IOS_GOOGLE_REDIRECT_URI:
+ 'com.googleusercontent.apps.795351133007-gvuagr9t7tfkak3sp2cmng4pdhchlfpd:/oauth2redirect/google',
+ ANDROID_GOOGLE_CLIENT_ID:
+ '795351133007-0po6dfbepae7klaso18qv61f86u4a2ef.apps.googleusercontent.com',
+ ANDROID_GOOGLE_SERVER_CLIENT_ID:
+ '795351133007-gh67d3hot6ib24htu9d7sh01bg90lpdu.apps.googleusercontent.com',
+ ANDROID_APPLE_CLIENT_ID: 'io.metamask.appleloginclient.flask.prod',
+
GOOGLE_GROUPED_AUTH_CONNECTION_ID: 'mm-google-flask-main',
APPLE_GROUPED_AUTH_CONNECTION_ID: 'mm-apple-flask-main',
AUTH_SERVER_URL: 'https://auth-service.api.cx.metamask.io',
@@ -77,6 +133,16 @@ export const OAUTH_CONFIG: Record = {
IOS_APPLE_AUTH_CONNECTION_ID: 'mm-apple-flask-main-common',
},
flask_uat: {
+ IOS_GOOGLE_CLIENT_ID:
+ '387141446914-1tdlsrare1jtjd2tgal9bi1ilb4qro5d.apps.googleusercontent.com',
+ IOS_GOOGLE_REDIRECT_URI:
+ 'com.googleusercontent.apps.387141446914-1tdlsrare1jtjd2tgal9bi1ilb4qro5d:/oauth2redirect/google',
+ ANDROID_GOOGLE_CLIENT_ID:
+ '387141446914-ki5586faf9qmlghop8g07f4a10scdevi.apps.googleusercontent.com',
+ ANDROID_GOOGLE_SERVER_CLIENT_ID:
+ '387141446914-f03k9ivc2jrmi1s53lne88mh529372kj.apps.googleusercontent.com',
+ ANDROID_APPLE_CLIENT_ID: 'io.metamask.appleloginclient.flask.uat',
+
GOOGLE_GROUPED_AUTH_CONNECTION_ID: 'mm-google-flask-uat',
APPLE_GROUPED_AUTH_CONNECTION_ID: 'mm-apple-flask-uat',
AUTH_SERVER_URL: 'https://auth-service.api.cx.metamask.io',
@@ -88,6 +154,16 @@ export const OAUTH_CONFIG: Record = {
IOS_APPLE_AUTH_CONNECTION_ID: 'mm-apple-flask-uat-common',
},
flask_dev: {
+ IOS_GOOGLE_CLIENT_ID:
+ '615965109465-89b2lmqgm5ka8j8t403qhooouv57id9b.apps.googleusercontent.com',
+ IOS_GOOGLE_REDIRECT_URI:
+ 'com.googleusercontent.apps.615965109465-89b2lmqgm5ka8j8t403qhooouv57id9b:/oauth2redirect/google',
+ ANDROID_GOOGLE_CLIENT_ID:
+ '615965109465-9nn2i74feqs3v9ps4lb61ha0v34eo382.apps.googleusercontent.com',
+ ANDROID_GOOGLE_SERVER_CLIENT_ID:
+ '615965109465-ab20kuqbls6fj5s50fvmvbnket8nv1sh.apps.googleusercontent.com',
+ ANDROID_APPLE_CLIENT_ID: 'io.metamask.appleloginclient.flask.dev',
+
GOOGLE_GROUPED_AUTH_CONNECTION_ID: 'mm-google-flask-dev',
APPLE_GROUPED_AUTH_CONNECTION_ID: 'mm-apple-flask-dev',
AUTH_SERVER_URL: 'https://auth-service.dev-api.cx.metamask.io',
diff --git a/app/core/OAuthService/OAuthLoginHandlers/constants.test.ts b/app/core/OAuthService/OAuthLoginHandlers/constants.test.ts
index d677f9edc64..481dfc8188b 100644
--- a/app/core/OAuthService/OAuthLoginHandlers/constants.test.ts
+++ b/app/core/OAuthService/OAuthLoginHandlers/constants.test.ts
@@ -47,6 +47,8 @@ jest.mock(
);
const mockAppRedirectUri = 'metamask://oauth-redirect';
+const CURRENT_OAUTH_CONFIG = OAUTH_CONFIG.main_prod;
+
describe('OAuth Constants', () => {
describe('AppRedirectUri', () => {
it('should generate correct redirect URI', () => {
@@ -55,8 +57,6 @@ describe('OAuth Constants', () => {
});
describe('Environment-based constants', () => {
- const CURRENT_OAUTH_CONFIG = OAUTH_CONFIG.main_prod;
-
it('should have web3AuthNetwork from jest config', () => {
expect(web3AuthNetwork).toBe('sapphire_mainnet');
});
@@ -65,9 +65,13 @@ describe('OAuth Constants', () => {
expect(AuthServerUrl).toBe(CURRENT_OAUTH_CONFIG.AUTH_SERVER_URL);
});
- it('should have Android configuration from jest config', () => {
- expect(GoogleWebGID).toBe('androidGoogleWebClientId');
- expect(AppleWebClientId).toBe('AppleClientId');
+ it('should have Android configuration from config', () => {
+ expect(GoogleWebGID).toBe(
+ CURRENT_OAUTH_CONFIG.ANDROID_GOOGLE_SERVER_CLIENT_ID,
+ );
+ expect(AppleWebClientId).toBe(
+ CURRENT_OAUTH_CONFIG.ANDROID_APPLE_CLIENT_ID,
+ );
});
it('should generate correct Apple server redirect URI', () => {
@@ -142,8 +146,8 @@ describe('getIosGoogleConfig', () => {
const config = getIosGoogleConfig();
expect(config).toEqual({
- clientId: 'iosGoogleClientId',
- redirectUri: 'iosGoogleRedirectUri',
+ clientId: CURRENT_OAUTH_CONFIG.IOS_GOOGLE_CLIENT_ID,
+ redirectUri: CURRENT_OAUTH_CONFIG.IOS_GOOGLE_REDIRECT_URI,
});
});
@@ -155,8 +159,8 @@ describe('getIosGoogleConfig', () => {
const config = getIosGoogleConfig();
expect(config).toEqual({
- clientId: 'iosGoogleClientId',
- redirectUri: 'iosGoogleRedirectUri',
+ clientId: CURRENT_OAUTH_CONFIG.IOS_GOOGLE_CLIENT_ID,
+ redirectUri: CURRENT_OAUTH_CONFIG.IOS_GOOGLE_REDIRECT_URI,
});
});
@@ -166,7 +170,9 @@ describe('getIosGoogleConfig', () => {
const config = getIosGoogleConfig();
- expect(config.clientId).toBe('androidGoogleWebClientId');
+ expect(config.clientId).toBe(
+ CURRENT_OAUTH_CONFIG.ANDROID_GOOGLE_SERVER_CLIENT_ID,
+ );
expect(config.redirectUri).toContain('link.metamask.io');
});
@@ -188,7 +194,9 @@ describe('getIosGoogleConfig', () => {
const config = getIosGoogleConfig();
- expect(config.clientId).toBe('androidGoogleWebClientId');
+ expect(config.clientId).toBe(
+ CURRENT_OAUTH_CONFIG.ANDROID_GOOGLE_SERVER_CLIENT_ID,
+ );
expect(config.redirectUri).toContain('link.metamask.io');
});
@@ -200,8 +208,8 @@ describe('getIosGoogleConfig', () => {
const config = getIosGoogleConfig();
expect(config).toEqual({
- clientId: 'iosGoogleClientId',
- redirectUri: 'iosGoogleRedirectUri',
+ clientId: CURRENT_OAUTH_CONFIG.IOS_GOOGLE_CLIENT_ID,
+ redirectUri: CURRENT_OAUTH_CONFIG.IOS_GOOGLE_REDIRECT_URI,
});
});
});
diff --git a/app/core/OAuthService/OAuthLoginHandlers/constants.ts b/app/core/OAuthService/OAuthLoginHandlers/constants.ts
index 6e34dbdd129..1f1d4fc5ae3 100644
--- a/app/core/OAuthService/OAuthLoginHandlers/constants.ts
+++ b/app/core/OAuthService/OAuthLoginHandlers/constants.ts
@@ -64,10 +64,12 @@ export const E2E_QA_MOCK_OAUTH_TOKEN_URL =
export const AUTH_SERVER_MARKETING_OPT_IN_PATH =
'/api/v1/oauth/marketing_opt_in_status';
-export const IosGID = process.env.IOS_GOOGLE_CLIENT_ID;
-export const IosGoogleRedirectUri = process.env.IOS_GOOGLE_REDIRECT_URI;
-export const GoogleWebGID = process.env.ANDROID_GOOGLE_SERVER_CLIENT_ID;
-export const AppleWebClientId = process.env.ANDROID_APPLE_CLIENT_ID;
+export const IosGID = CURRENT_OAUTH_CONFIG.IOS_GOOGLE_CLIENT_ID;
+export const IosGoogleRedirectUri =
+ CURRENT_OAUTH_CONFIG.IOS_GOOGLE_REDIRECT_URI;
+export const GoogleWebGID =
+ CURRENT_OAUTH_CONFIG.ANDROID_GOOGLE_SERVER_CLIENT_ID;
+export const AppleWebClientId = CURRENT_OAUTH_CONFIG.ANDROID_APPLE_CLIENT_ID;
// Use universal link for OAuth redirect
export const GoogleRedirectUri = `${PROTOCOLS.HTTPS}://${AppConstants.MM_IO_UNIVERSAL_LINK_HOST}/${ACTIONS.OAUTH_REDIRECT}`;
From ae4e8f01bc0834da64d993942fc1f6c3a021633b Mon Sep 17 00:00:00 2001
From: Jorge Carrasco
Date: Thu, 7 May 2026 08:39:16 +0200
Subject: [PATCH 02/13] feat: add CocoaPods specs cache to iOS production build
(#29798)
## **Description**
The iOS production build in `build.yml` runs `pod install` without
caching `~/.cocoapods/repos` (the CocoaPods specs repository). Every run
downloads the full specs catalog from CDN, taking ~1m 36s.
This PR adds an `actions/cache@v4` step before `pod install` that caches
`~/.cocoapods/repos` keyed on `ios/Podfile.lock`. On cache hit, `pod
install` resolves specs locally instead of fetching from CDN. Uses
`continue-on-error: true` so cache failures never block the build.
This matches the existing pattern in
`.github/actions/setup-e2e-env/action.yml` (lines 382-391).
## **Changelog**
CHANGELOG entry: null
## **Related issues**
Fixes: MCWP-574
## **Manual testing steps**
```gherkin
Feature: CocoaPods specs caching in iOS production build
Scenario: First run populates the cache (cache miss)
Given a production iOS build is triggered via workflow_dispatch
And no prior CocoaPods specs cache exists for the current Podfile.lock
When the "Restore CocoaPods specs cache (iOS)" step runs
Then the step logs show "Cache not found"
And "Install CocoaPods dependencies (iOS)" completes successfully at baseline timing (~1m 36s)
Scenario: Subsequent run restores the cache (cache hit)
Given a production iOS build is triggered via workflow_dispatch
And a CocoaPods specs cache exists from a previous run
When the "Restore CocoaPods specs cache (iOS)" step runs
Then the step logs show "Cache restored"
And "Install CocoaPods dependencies (iOS)" completes faster (~30s-1m less)
And the build produces a valid IPA artifact
Scenario: Cache failure does not block the build
Given the cache action encounters an error (network issue, quota exceeded)
When the "Restore CocoaPods specs cache (iOS)" step fails
Then the step is marked as successful due to continue-on-error: true
And "Install CocoaPods dependencies (iOS)" proceeds normally with a full CDN fetch
```
## **Screenshots/Recordings**
N/A - CI workflow change only, no UI impact.
### **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
- [ ] I've included tests if applicable
- [ ] 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.
---
.github/workflows/build.yml | 10 ++++++++++
1 file changed, 10 insertions(+)
diff --git a/.github/workflows/build.yml b/.github/workflows/build.yml
index 4d48f758bed..24e6f2785e2 100644
--- a/.github/workflows/build.yml
+++ b/.github/workflows/build.yml
@@ -316,6 +316,16 @@ jobs:
SECRETS_JSON: ${{ toJSON(secrets) }}
run: node scripts/validate-secrets-from-config.js
+ - name: Restore CocoaPods specs cache (iOS)
+ if: matrix.platform == 'ios'
+ uses: actions/cache@v4
+ with:
+ path: ~/.cocoapods/repos
+ key: ${{ runner.os }}-cocoapods-specs-${{ hashFiles('ios/Podfile.lock') }}
+ restore-keys: |
+ ${{ runner.os }}-cocoapods-specs-
+ continue-on-error: true
+
# iOS: Install Pods here so generated paths match this runner (setup-node-modules skips pod install with --no-install-pods).
- name: Install CocoaPods dependencies (iOS)
if: matrix.platform == 'ios'
From a492e349085f8072412e299f120a6f85abaf10bb Mon Sep 17 00:00:00 2001
From: Jorge Carrasco
Date: Thu, 7 May 2026 08:43:33 +0200
Subject: [PATCH 03/13] feat: use shallow clone in prepare and
emit-build-metadata jobs (#29807)
## **Description**
The `prepare` and `emit-build-metadata` jobs in `build.yml` use
`fetch-depth: 0` (full git history clone), which takes ~1m 10s each
(median, n=21). Neither job performs any git history operations:
- `prepare`: reads `builds.yml` via `fs.readFileSync` and runs
`validate-build-config.js` (zero git commands)
- `emit-build-metadata`: runs `git rev-parse HEAD` (works on shallow
clones) and `get-build-metadata.sh` which only reads files (zero git
commands)
Removing `fetch-depth: 0` defaults to `fetch-depth: 1` (shallow clone),
reducing checkout from ~70s to ~10-15s per job.
**Expected saving:** ~1m 50s - 2m 10s per pipeline run across both jobs.
Also eliminates a tail-risk outlier where `emit-build-metadata` checkout
took 6m 45s.
Part of MCWP-574 (pipeline optimization series: PR 3 of 4).
## **Changelog**
CHANGELOG entry: null
## **Related issues**
Fixes: MCWP-574
## **Manual testing steps**
```gherkin
Feature: Shallow clone in build pipeline
Scenario: prepare job completes with shallow clone
Given a production build is triggered via workflow_dispatch on build.yml
And skip_version_bump is true
When the prepare job runs
Then the actions/checkout step completes in ~10-15s (down from ~70s)
And the prepare job outputs are identical to a full-clone run
Scenario: emit-build-metadata job completes with shallow clone
Given a production build completes successfully
When the emit-build-metadata job runs
Then the actions/checkout step completes in ~10-15s
And built_commit_sha, semantic_version, and version codes are output correctly
Scenario: version-bump commit ref works with shallow clone
Given a production build is triggered with skip_version_bump false
When the prepare job checks out the version-bump commit hash
Then actions/checkout fetches the specific commit successfully
And the prepare job completes without errors
```
## **Screenshots/Recordings**
### **Before**
Not applicable (CI pipeline change, no UI).
### **After**
Not applicable (CI pipeline change, no UI).
## **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
- [ ] 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.
---
.github/workflows/build.yml | 2 --
1 file changed, 2 deletions(-)
diff --git a/.github/workflows/build.yml b/.github/workflows/build.yml
index 24e6f2785e2..eb7be66eb01 100644
--- a/.github/workflows/build.yml
+++ b/.github/workflows/build.yml
@@ -117,7 +117,6 @@ jobs:
steps:
- uses: actions/checkout@v4
with:
- fetch-depth: 0
ref: ${{ !inputs.skip_version_bump && needs.update-build-version.outputs.commit-hash || (inputs.source_branch || github.ref_name) }}
- name: Setup Node.js
uses: actions/setup-node@v4
@@ -528,7 +527,6 @@ jobs:
steps:
- uses: actions/checkout@v4
with:
- fetch-depth: 0
ref: ${{ needs.prepare.outputs.checkout_ref_for_setup }}
- name: Setup Node.js
From 728139575dfed461c6fe165a23131f3f4d8036da Mon Sep 17 00:00:00 2001
From: Ramon AC <36987446+racitores@users.noreply.github.com>
Date: Thu, 7 May 2026 09:42:30 +0200
Subject: [PATCH 04/13] test: add component view tests and skip duplicated
smoke E2E (#28911)
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
## **Description**
This PR adds **component view tests (CVT)** for flows that were
previously covered by **smoke E2E** only, and **skips** those E2E cases
(eventually delete them). Smoke specs keep the original test bodies and
point to the CV file with `// Moved to cv tests (...)`.
Example: In network abstraction shard 1 we see a 4m reduction time
(android).
### E2E → component view test mapping
| File | Test Name | QA Comment | CV test file |
| --- | --- | --- | --- |
| view-defi-details.spec.ts | view DeFi position details | just checking
some data in the screen |
`app/components/UI/DeFiPositions/DeFiProtocolPositionDetails.view.test.tsx`
|
| view-market-insights.spec.ts | displays market insights content and
navigates to swap | |
`app/components/UI/MarketInsights/Views/MarketInsightsView/MarketInsightsView.view.test.tsx`
|
| view-market-insights.spec.ts | does not display entry card when API
returns no data | |
`app/components/UI/TokenDetails/components/AssetOverviewContent.view.test.tsx`
|
| view-market-insights.spec.ts | does not display entry card when
feature flag is disabled | |
`app/components/UI/TokenDetails/components/AssetOverviewContent.view.test.tsx`
|
| view-market-insights.spec.ts | navigates to buy screen when tapping
Buy button | |
`app/components/UI/MarketInsights/Views/MarketInsightsView/MarketInsightsView.view.test.tsx`
|
| view-market-insights.spec.ts | can tap thumbs up feedback button | |
`app/components/UI/MarketInsights/Views/MarketInsightsView/MarketInsightsView.view.test.tsx`
|
| send-btc-token.spec.ts | shows insufficient funds | This only does
validation on the input |
`app/components/Views/confirmations/components/send/send.non-evm.view.test.tsx`
|
| send-tron-token.spec.ts | shows insufficient funds | This only does
validation on the input |
`app/components/Views/confirmations/components/send/send.non-evm.view.test.tsx`
|
| send-erc20-token.spec.ts | should send USDC amount 50% to an address |
CV tests will handle these combinations |
`app/components/Views/confirmations/components/send/send.view.test.tsx`
|
| send-erc20-token.spec.ts | should send USDC send max to an address |
CV tests will handle these combinations |
`app/components/Views/confirmations/components/send/send.view.test.tsx`
|
| send-native-token.spec.ts | should send ETH to an address | PARTIALLY:
We should only cover ETH send, no need to cover 50% and Max |
`app/components/Views/confirmations/components/send/send.view.test.tsx`
|
| send-solana-token.spec.ts | should send solana to an address | This is
not actually sending anything, just checking that the text matches |
`app/components/Views/confirmations/components/send/send.non-evm.view.test.tsx`
|
| alert-system.spec.ts | should sign typed message | Moved to yes as per
team review |
`app/components/Views/confirmations/components/alert-banner/alert-system-typed-sign-blockaid.view.test.tsx`
|
| alert-system.spec.ts | should show security alert for malicious
request, acknowledge and confirm the signature | Moved to yes as per
team review |
`app/components/Views/confirmations/components/alert-banner/alert-system-typed-sign-blockaid.view.test.tsx`
|
| alert-system.spec.ts | should show security alert for error when
validating request fails | |
`app/components/Views/confirmations/components/alert-banner/alert-system-security-failed.view.test.tsx`
|
| alert-system.spec.ts | should show mismatch field alert, click the
alert, acknowledge and confirm the signature | As long as the component
is the same we can do this via CV test |
`app/components/Views/confirmations/components/alert-banner/alert-system-siwe-inline-mismatch.view.test.tsx`
|
| gas-fee-tokens-eip-7702-sponsored.spec.ts | fails transaction if error
occurs on API | |
`app/components/Views/confirmations/components/activity/eip-7702-sponsored-relay-api-failure.view.test.tsx`
|
| enable-notifications-after-onboarding.spec.ts | should enable
notifications and view feature announcements and wallet notifications |
Test is not doing what its title implies; skipped pending owner
discussion |
`app/components/Views/Notifications/NotificationsView.view.test.tsx` |
| notification-settings-flow.spec.ts | should enable notifications and
toggle feature announcements and account notifications | UI-only
validation, suitable for CV |
`app/components/Views/Settings/NotificationsSettings/NotificationsSettings.view.test.tsx`
|
| add-popular-networks.spec.ts | adds a popular network directly without
confirmation modal | This is not in prod anymore | *No matching
`*.view.test.tsx` on this branch* |
## **Changelog**
CHANGELOG entry: null
## **Related issues**
Fixes:
## **Manual testing steps**
```gherkin
Feature: N/A
Scenario: Automated tests only
Given developer checks out this branch
When they run yarn test:view for the touched view test files
Then tests pass
```
## **Screenshots/Recordings**
### **Before**
N/A
### **After**
N/A
## **Pre-merge author checklist**
- [x] I've followed [MetaMask Contributor
Docs](https://github.com/MetaMask/contributor-docs) and [MetaMask Mobile
Coding
Standards](https://github.com/MetaMask/metamask-mobile/blob/main/.github/guidelines/CODING_GUIDELINES.md).
- [x] I've completed the PR template to the best of my ability
- [x] I've included tests if applicable
- [x] I've documented my code using [JSDoc](https://jsdoc.app/) format
if applicable
- [x] I've applied the right labels on the PR (see [labeling
guidelines](https://github.com/MetaMask/metamask-mobile/blob/main/.github/guidelines/LABELING_GUIDELINES.md)).
Not required for external contributors.
#### Performance checks (if applicable)
- [x] I've tested on Android
- Ideally on a mid-range device; emulator is acceptable
- [x] I've tested with a power user scenario
- Use these [power-user
SRPs](https://consensyssoftware.atlassian.net/wiki/spaces/TL1/pages/edit-v2/401401446401?draftShareId=9d77e1e1-4bdc-4be1-9ebb-ccd916988d93)
to import wallets with many accounts and tokens
- [x] I've instrumented key operations with Sentry traces for production
performance metrics
- See [`trace()`](/app/util/trace.ts) for usage and
[`addToken`](/app/components/Views/AddAsset/components/AddCustomToken/AddCustomToken.tsx#L274)
for an example
For performance guidelines and tooling, see the [Performance
Guide](https://consensyssoftware.atlassian.net/wiki/spaces/TL1/pages/400085549067/Performance+Guide+for+Engineers).
## **Pre-merge reviewer checklist**
- [ ] 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 product risk since changes are test-only, but moderate test-suite
risk due to new integration-style view tests, new engine/nock mocks, and
`jest.config.view.js` forcing `IS_TEST=true` for feature-gated code
paths.
>
> **Overview**
> Adds **component-view (CV) test coverage** for several flows
previously validated only by smoke E2E: DeFi protocol position details,
token Market Insights (including entry card gating + swap/buy navigation
+ sources sheet + feedback), notifications list/details and notification
settings toggles, confirmation alert-system (typed-sign Blockaid
benign/malicious + SIWE domain mismatch inline + validation-failed
banner), and EIP-7702 sponsored send (failed activity status + “Paid by
MetaMask” fee row).
>
> Extends CV test infrastructure with new presets/helpers and mocks
(notifications state seeding, Market Insights navigation
renderer/preset, SnapController request interceptor, Sentinel
`/networks` nock mock), adds/normalizes several `testId` constants (send
50% button, confirmation transfer loader, status-icon tooltip), and sets
`process.env.IS_TEST=true` at view-jest config load time to satisfy
env-inlined feature gates.
>
> Removes or skips corresponding smoke E2E specs (or individual cases)
and updates fixtures/assertions (e.g., SIWE signer address) to align
with the new CV coverage.
>
> Reviewed by [Cursor Bugbot](https://cursor.com/bugbot) for commit
6098045cd53502abeb1a15f26006c0914fe609e7. Bugbot is set up for automated
code reviews on this repo. Configure
[here](https://www.cursor.com/dashboard/bugbot).
---------
Co-authored-by: Cursor
---
.../DeFiProtocolPositionDetails.view.test.tsx | 139 +++++
.../MarketInsightsView.view.test.tsx | 182 +++++++
.../AssetOverviewContent.view.test.tsx | 231 +++++++++
.../NotificationsView.view.test.tsx | 169 ++++++
.../NotificationsSettings.view.test.tsx | 200 ++++++++
.../confirmations/ConfirmationView.testIds.ts | 4 +
...-sponsored-relay-api-failure.view.test.tsx | 107 ++++
...alert-system-security-failed.view.test.tsx | 88 ++++
...-system-siwe-inline-mismatch.view.test.tsx | 133 +++++
...t-system-typed-sign-blockaid.view.test.tsx | 197 +++++++
.../rows/origin-row/origin-row.test.tsx | 2 +-
.../send/RedesignedSendView.testIds.ts | 1 +
.../send/send.non-evm.view.test.tsx | 480 ++++++++++++++++++
.../components/send/send.view.test.tsx | 325 +++++++-----
.../status-icon/status-icon.testIds.ts | 3 +
.../components/status-icon/status-icon.tsx | 3 +-
app/util/test/confirm-data-helpers.ts | 1 +
jest.config.view.js | 19 +
.../api-mocking/sentinel-networks.ts | 58 +++
.../fixtures/perpsMarketInsights.ts | 3 +
tests/component-view/helpers/snapRequests.ts | 75 +++
tests/component-view/mocks.ts | 51 ++
.../presets/marketInsightsView.ts | 29 ++
tests/component-view/presets/notifications.ts | 156 ++++++
tests/component-view/presets/send.ts | 51 +-
.../renderers/marketInsights.tsx | 122 +++++
.../assets/defi/view-defi-details.spec.ts | 79 ---
.../view-market-insights.spec.ts | 192 -------
.../confirmations/send/send-btc-token.spec.ts | 40 +-
.../send/send-erc20-token.spec.ts | 129 +----
.../send/send-native-token.spec.ts | 32 +-
.../send/send-solana-token.spec.ts | 63 +--
.../send/send-tron-token.spec.ts | 25 +-
.../signatures/alert-system.spec.ts | 265 ----------
.../gas-fee-tokens-eip-7702-sponsored.spec.ts | 2 +
...ble-notifications-after-onboarding.spec.ts | 1 +
.../notification-settings-flow.spec.ts | 3 +-
37 files changed, 2739 insertions(+), 921 deletions(-)
create mode 100644 app/components/UI/DeFiPositions/DeFiProtocolPositionDetails.view.test.tsx
create mode 100644 app/components/UI/MarketInsights/Views/MarketInsightsView/MarketInsightsView.view.test.tsx
create mode 100644 app/components/UI/TokenDetails/components/AssetOverviewContent.view.test.tsx
create mode 100644 app/components/Views/Notifications/NotificationsView.view.test.tsx
create mode 100644 app/components/Views/Settings/NotificationsSettings/NotificationsSettings.view.test.tsx
create mode 100644 app/components/Views/confirmations/components/activity/eip-7702-sponsored-relay-api-failure.view.test.tsx
create mode 100644 app/components/Views/confirmations/components/alert-banner/alert-system-security-failed.view.test.tsx
create mode 100644 app/components/Views/confirmations/components/alert-banner/alert-system-siwe-inline-mismatch.view.test.tsx
create mode 100644 app/components/Views/confirmations/components/alert-banner/alert-system-typed-sign-blockaid.view.test.tsx
create mode 100644 app/components/Views/confirmations/components/send/send.non-evm.view.test.tsx
create mode 100644 app/components/Views/confirmations/components/status-icon/status-icon.testIds.ts
create mode 100644 tests/component-view/api-mocking/sentinel-networks.ts
create mode 100644 tests/component-view/helpers/snapRequests.ts
create mode 100644 tests/component-view/presets/marketInsightsView.ts
create mode 100644 tests/component-view/presets/notifications.ts
create mode 100644 tests/component-view/renderers/marketInsights.tsx
delete mode 100644 tests/smoke/assets/defi/view-defi-details.spec.ts
delete mode 100644 tests/smoke/assets/market-insights/view-market-insights.spec.ts
delete mode 100644 tests/smoke/confirmations/signatures/alert-system.spec.ts
diff --git a/app/components/UI/DeFiPositions/DeFiProtocolPositionDetails.view.test.tsx b/app/components/UI/DeFiPositions/DeFiProtocolPositionDetails.view.test.tsx
new file mode 100644
index 00000000000..b0c917f077b
--- /dev/null
+++ b/app/components/UI/DeFiPositions/DeFiProtocolPositionDetails.view.test.tsx
@@ -0,0 +1,139 @@
+import '../../../../tests/component-view/mocks';
+import React from 'react';
+import { FlatList } from 'react-native';
+import { act, fireEvent } from '@testing-library/react-native';
+import type { GroupedDeFiPositions } from '@metamask/assets-controllers';
+
+import DeFiProtocolPositionDetails, {
+ DEFI_PROTOCOL_POSITION_DETAILS_BALANCE_TEST_ID,
+} from './DeFiProtocolPositionDetails';
+import { WalletViewSelectorsIDs } from '../../Views/Wallet/WalletView.testIds';
+import { renderComponentViewScreen } from '../../../../tests/component-view/render';
+import { describeForPlatforms } from '../../../../tests/component-view/platform';
+import { backgroundState } from '../../../util/test/initial-root-state';
+
+/**
+ * Mirrors smoke `view-defi-details`: tap Aave V3 → read-only position details with
+ * Supplied tokens and fiat balances (no transaction).
+ */
+const aaveV3PositionAggregate: GroupedDeFiPositions['protocols'][number] = {
+ protocolDetails: {
+ name: 'Aave V3',
+ iconUrl: '',
+ },
+ aggregatedMarketValue: 14.74,
+ positionTypes: {
+ supply: {
+ aggregatedMarketValue: 14.74,
+ positions: [
+ [
+ {
+ address: '0x23878914efe38d27c4d67ab83ed1b93a74d4086a',
+ name: 'Aave Ethereum USDT',
+ symbol: 'aEthUSDT',
+ decimals: 6,
+ balance: 0.300112,
+ balanceRaw: '300112',
+ marketValue: 14.74,
+ type: 'protocol',
+ tokens: [
+ {
+ address: '0xdac17f958d2ee523a2206206994597c13d831ec7',
+ name: 'Tether USD',
+ symbol: 'USDT',
+ decimals: 6,
+ balance: 0.300112,
+ balanceRaw: '300112',
+ marketValue: 14.74,
+ price: 0.99994,
+ type: 'underlying',
+ iconUrl: '',
+ },
+ ],
+ },
+ ],
+ [
+ {
+ address: '0xfa1fdbbd71b0aa16162d76914d69cd8cb3ef92da',
+ name: 'Aave Ethereum Lido WETH',
+ symbol: 'aEthLidoWETH',
+ decimals: 18,
+ balance: 1e-5,
+ balanceRaw: '9030902767263172',
+ marketValue: 0.3,
+ type: 'protocol',
+ tokens: [
+ {
+ address: '0xc02aaa39b223fe8d0a0e5c4f27ead9083c756cc2',
+ name: 'Wrapped Ether',
+ symbol: 'WETH',
+ decimals: 18,
+ balance: 1e-5,
+ balanceRaw: '10000000000000',
+ marketValue: 0.3,
+ price: 1599.45,
+ type: 'underlying',
+ iconUrl: '',
+ },
+ ],
+ },
+ ],
+ ],
+ },
+ },
+};
+
+const defiDetailsState = {
+ engine: {
+ backgroundState: {
+ ...backgroundState,
+ PreferencesController: {
+ ...backgroundState.PreferencesController,
+ privacyMode: false,
+ },
+ },
+ },
+};
+
+describeForPlatforms('DeFi position details (read-only)', () => {
+ it('shows Aave V3 supplied assets with token symbols and fiat amounts', () => {
+ const { getByTestId, getByText, getAllByText, UNSAFE_getByType } =
+ renderComponentViewScreen(
+ DeFiProtocolPositionDetails,
+ { name: 'DeFiProtocolPositionDetails' },
+ { state: defiDetailsState },
+ {
+ protocolAggregate: aaveV3PositionAggregate,
+ networkIconAvatar: undefined,
+ },
+ );
+
+ expect(
+ getByTestId(WalletViewSelectorsIDs.DEFI_POSITIONS_DETAILS_CONTAINER),
+ ).toBeOnTheScreen();
+
+ expect(getByText('Aave V3')).toBeOnTheScreen();
+ expect(
+ getByTestId(DEFI_PROTOCOL_POSITION_DETAILS_BALANCE_TEST_ID),
+ ).toHaveTextContent('$14.74');
+
+ // Smoke parity for details checks: Supplied + USDT + WETH + $14.74 + $0.30.
+ expect(getAllByText('Supplied')).toHaveLength(2);
+ expect(getAllByText('USDT')).toHaveLength(1);
+ expect(getAllByText('$14.74').length).toBeGreaterThanOrEqual(2);
+
+ const list = UNSAFE_getByType(FlatList);
+ act(() => {
+ fireEvent.scroll(list, {
+ nativeEvent: {
+ contentOffset: { y: 150 },
+ contentSize: { height: 500, width: 400 },
+ layoutMeasurement: { height: 400, width: 400 },
+ },
+ });
+ });
+
+ expect(getByText('WETH')).toBeOnTheScreen();
+ expect(getByText('$0.30')).toBeOnTheScreen();
+ });
+});
diff --git a/app/components/UI/MarketInsights/Views/MarketInsightsView/MarketInsightsView.view.test.tsx b/app/components/UI/MarketInsights/Views/MarketInsightsView/MarketInsightsView.view.test.tsx
new file mode 100644
index 00000000000..45ad955674f
--- /dev/null
+++ b/app/components/UI/MarketInsights/Views/MarketInsightsView/MarketInsightsView.view.test.tsx
@@ -0,0 +1,182 @@
+/**
+ * Component view tests for token (non-Perps) MarketInsightsView: content,
+ * swap/buy navigation, trend sources sheet, thumbs up.
+ * Mirrors smoke: tests/smoke/assets/market-insights/view-market-insights.spec.ts
+ * (cases 7, 10, 11, 12). Entry card visibility cases (8, 9) are covered by
+ * AssetOverviewContent.view.test.tsx.
+ * Run: yarn test:view:one MarketInsightsView.view.test.tsx
+ */
+import '../../../../../../tests/component-view/mocks';
+import { fireEvent, screen, waitFor } from '@testing-library/react-native';
+import { CHAIN_IDS } from '@metamask/transaction-controller';
+import {
+ MOCK_PERPS_MARKET_INSIGHTS_REPORT,
+ setupMarketInsightsEngineMock,
+} from '../../../../../../tests/component-view/fixtures/perpsMarketInsights';
+import { renderMarketInsightsViewWithNavigation } from '../../../../../../tests/component-view/renderers/marketInsights';
+import { describeForPlatforms } from '../../../../../../tests/component-view/platform';
+import { BuildQuoteSelectors } from '../../../Ramp/Aggregator/Views/BuildQuote/BuildQuote.testIds';
+import { MarketInsightsSelectorsIDs } from '../../MarketInsights.testIds';
+import { analytics } from '../../../../../util/analytics/analytics';
+import { resetFeedbackCache } from './MarketInsightsView';
+
+const ETH_MAINNET_ROUTE_PARAMS = {
+ assetSymbol: 'ETH',
+ assetIdentifier: 'eip155:1/slip44:60',
+ tokenAddress: '0x0000000000000000000000000000000000000000',
+ tokenDecimals: 18,
+ tokenName: 'Ethereum',
+ tokenChainId: CHAIN_IDS.MAINNET,
+ token: {
+ address: '0x123',
+ symbol: 'ETH',
+ decimals: 18,
+ name: 'Ethereum',
+ chainId: '0x1',
+ image: 'https://example.com/eth.png',
+ balance: '0',
+ logo: undefined,
+ },
+};
+
+describeForPlatforms('MarketInsightsView (token flow)', () => {
+ beforeEach(() => {
+ setupMarketInsightsEngineMock(MOCK_PERPS_MARKET_INSIGHTS_REPORT);
+ });
+
+ afterEach(() => {
+ resetFeedbackCache();
+ });
+
+ it('displays market insights content and navigates to swap', async () => {
+ renderMarketInsightsViewWithNavigation({
+ initialParams: ETH_MAINNET_ROUTE_PARAMS,
+ overrides: {
+ engine: {
+ backgroundState: {
+ TokensController: {
+ allTokens: {
+ '0x1': {
+ '0x0000000000000000000000000000000000000001': [
+ {
+ address: '0xa0b86991c6218b36c1d19d4a2e9eb0ce3606eb48',
+ decimals: 6,
+ symbol: 'USDC',
+ name: 'USD Coin',
+ image: '',
+ },
+ ],
+ },
+ },
+ allIgnoredTokens: {},
+ },
+ TokenBalancesController: {
+ tokenBalances: {
+ '0x0000000000000000000000000000000000000001': {
+ '0x1': {
+ '0xa0b86991c6218b36c1d19d4a2e9eb0ce3606eb48': '0x3b9aca00',
+ },
+ },
+ },
+ },
+ TokenRatesController: {
+ marketData: {
+ '0x1': {
+ '0xa0b86991c6218b36c1d19d4a2e9eb0ce3606eb48': {
+ tokenAddress: '0xa0b86991c6218b36c1d19d4a2e9eb0ce3606eb48',
+ currency: 'ETH',
+ price: 0.0005,
+ },
+ },
+ },
+ },
+ },
+ },
+ },
+ });
+
+ expect(
+ await screen.findByTestId(MarketInsightsSelectorsIDs.VIEW_CONTAINER),
+ ).toBeOnTheScreen();
+ expect(
+ await screen.findByText(
+ 'Ethereum shows strong momentum amid institutional demand',
+ ),
+ ).toBeOnTheScreen();
+ expect(
+ await screen.findByText(
+ 'Ethereum continues to attract institutional interest with increasing on-chain activity and a healthy DeFi ecosystem.',
+ ),
+ ).toBeOnTheScreen();
+ expect(await screen.findByText('Institutional Adoption')).toBeOnTheScreen();
+ expect(await screen.findByText('DeFi Activity Surge')).toBeOnTheScreen();
+
+ fireEvent.press(
+ await screen.findByTestId(MarketInsightsSelectorsIDs.SWAP_BUTTON),
+ );
+
+ expect(await screen.findByTestId('route-BridgeView')).toBeOnTheScreen();
+ });
+
+ it('navigates to buy flow when tapping Buy button', async () => {
+ renderMarketInsightsViewWithNavigation({
+ initialParams: ETH_MAINNET_ROUTE_PARAMS,
+ });
+
+ await screen.findByTestId(MarketInsightsSelectorsIDs.VIEW_CONTAINER);
+
+ fireEvent.press(
+ await screen.findByTestId(MarketInsightsSelectorsIDs.BUY_BUTTON),
+ );
+
+ expect(
+ await screen.findByTestId(BuildQuoteSelectors.CONTINUE_BUTTON),
+ ).toBeOnTheScreen();
+ });
+
+ it('shows sources bottom sheet when tapping a trend item', async () => {
+ renderMarketInsightsViewWithNavigation({
+ initialParams: ETH_MAINNET_ROUTE_PARAMS,
+ });
+
+ await screen.findByTestId(MarketInsightsSelectorsIDs.VIEW_CONTAINER);
+
+ fireEvent.press(
+ await screen.findByTestId(`${MarketInsightsSelectorsIDs.TREND_ITEM}-0`),
+ );
+
+ expect(
+ await screen.findByText('Spot Ethereum ETFs See Record Weekly Inflows'),
+ ).toBeOnTheScreen();
+ });
+
+ it('can tap thumbs up feedback button', async () => {
+ const trackEventSpy = jest.spyOn(analytics, 'trackEvent');
+ try {
+ renderMarketInsightsViewWithNavigation({
+ initialParams: ETH_MAINNET_ROUTE_PARAMS,
+ });
+
+ await screen.findByTestId(MarketInsightsSelectorsIDs.VIEW_CONTAINER);
+
+ const thumbsUp = await screen.findByTestId(
+ MarketInsightsSelectorsIDs.THUMBS_UP_BUTTON,
+ );
+ trackEventSpy.mockClear();
+ fireEvent.press(thumbsUp);
+
+ await waitFor(() => {
+ expect(trackEventSpy).toHaveBeenCalledWith(
+ expect.objectContaining({
+ name: 'Market Insights Interaction',
+ properties: expect.objectContaining({
+ interaction_type: 'thumbs_up',
+ }),
+ }),
+ );
+ });
+ } finally {
+ trackEventSpy.mockRestore();
+ }
+ });
+});
diff --git a/app/components/UI/TokenDetails/components/AssetOverviewContent.view.test.tsx b/app/components/UI/TokenDetails/components/AssetOverviewContent.view.test.tsx
new file mode 100644
index 00000000000..0c3bc858d9e
--- /dev/null
+++ b/app/components/UI/TokenDetails/components/AssetOverviewContent.view.test.tsx
@@ -0,0 +1,231 @@
+import '../../../../../tests/component-view/mocks';
+import React from 'react';
+import { Text } from 'react-native';
+import { createStackNavigator } from '@react-navigation/stack';
+import { merge } from 'lodash';
+import { fireEvent, screen, waitFor } from '@testing-library/react-native';
+
+import renderWithProvider, {
+ type ProviderValues,
+} from '../../../../util/test/renderWithProvider';
+
+import AssetOverviewContent, {
+ type AssetOverviewContentProps,
+} from './AssetOverviewContent';
+import { TokenI } from '../../Tokens/types';
+import { TimePeriod } from '../../../hooks/useTokenHistoricalPrices';
+import { TokenOverviewSelectorsIDs } from '../../AssetOverview/TokenOverview.testIds';
+import { MarketInsightsSelectorsIDs } from '../../MarketInsights/MarketInsights.testIds';
+import { remoteFeatureFlagMarketInsightsEnabled } from '../../../../../tests/api-mocking/mock-responses/feature-flags-mocks';
+import {
+ MOCK_PERPS_MARKET_INSIGHTS_REPORT,
+ setupMarketInsightsEngineMock,
+} from '../../../../../tests/component-view/fixtures/perpsMarketInsights';
+import { initialStateAssetDetails } from '../../../../../tests/component-view/presets/assetDetails';
+import {
+ fiatOrdersRampRoutingSupported,
+ initialStateMarketInsightsView,
+} from '../../../../../tests/component-view/presets/marketInsightsView';
+import { describeForPlatforms } from '../../../../../tests/component-view/platform';
+import Routes from '../../../../constants/navigation/Routes';
+import MarketInsightsView from '../../MarketInsights/Views/MarketInsightsView/MarketInsightsView';
+import { AccessRestrictedProvider } from '../../Compliance';
+
+const ETH_NATIVE = '0x0000000000000000000000000000000000000000';
+
+const ethMainnetToken: TokenI = {
+ address: ETH_NATIVE,
+ chainId: '0x1',
+ symbol: 'ETH',
+ name: 'Ethereum',
+ decimals: 18,
+ balance: '1',
+ balanceFiat: '$2000',
+ logo: '',
+ image: '',
+ isETH: true,
+ isNative: true,
+ hasBalanceError: false,
+ aggregators: [],
+};
+
+const baseOverviewProps: AssetOverviewContentProps = {
+ token: ethMainnetToken,
+ balance: '1',
+ mainBalance: '$2,000.00',
+ secondaryBalance: '1 ETH',
+ currentPrice: 2000,
+ priceDiff: 0,
+ comparePrice: 2000,
+ prices: [],
+ isLoading: false,
+ timePeriod: '1d' as TimePeriod,
+ setTimePeriod: () => undefined,
+ chartNavigationButtons: ['1d', '1w', '1m', '3m', '1y', '3y'],
+ isPerpsEnabled: false,
+ currentCurrency: 'USD',
+ onBuy: () => undefined,
+ onSend: async () => undefined,
+ onReceive: () => undefined,
+};
+
+function AssetOverviewContentHarness() {
+ return ;
+}
+
+function renderAssetOverviewMarketInsightsStack(
+ extraRoutes: {
+ name: string;
+ Component?: React.ComponentType;
+ }[],
+ providerValues: ProviderValues,
+) {
+ const Stack = createStackNavigator();
+
+ const DefaultRouteProbe =
+ (routeName: string): React.FC =>
+ () => {routeName};
+
+ return renderWithProvider(
+
+
+
+ {extraRoutes.map(({ name, Component: Extra }) => (
+
+ ))}
+
+ ,
+ providerValues,
+ );
+}
+
+/**
+ * Bridge + ramps + multichain balances (MarketInsightsView preset) merged with Asset
+ * Details preset so `AssetOverviewContent` selectors (Earn, staking, etc.) resolve.
+ */
+function buildTokenDetailsMarketInsightsState(
+ marketInsightsFlagEnabled: boolean,
+) {
+ return merge(
+ {},
+ initialStateAssetDetails({ deterministicFiat: true }).build(),
+ initialStateMarketInsightsView()
+ .withOverrides(fiatOrdersRampRoutingSupported)
+ .build(),
+ {
+ engine: {
+ backgroundState: {
+ TokenListController: {
+ tokensChainsCache: {},
+ },
+ RemoteFeatureFlagController: {
+ remoteFeatureFlags: remoteFeatureFlagMarketInsightsEnabled(
+ marketInsightsFlagEnabled,
+ ),
+ },
+ EarnController: {
+ pooled_staking: { isEligible: false },
+ lending: { positions: [], markets: [] },
+ },
+ },
+ },
+ },
+ );
+}
+
+describeForPlatforms(
+ 'AssetOverviewContent (Market Insights entry card)',
+ () => {
+ it('does not show entry card or skeleton after fetch when API returns no report', async () => {
+ setupMarketInsightsEngineMock(null);
+
+ renderAssetOverviewMarketInsightsStack(
+ [
+ {
+ name: Routes.MARKET_INSIGHTS.VIEW,
+ Component:
+ MarketInsightsView as unknown as React.ComponentType,
+ },
+ ],
+ { state: buildTokenDetailsMarketInsightsState(true) },
+ );
+
+ await waitFor(
+ () => {
+ expect(
+ screen.queryByTestId(
+ MarketInsightsSelectorsIDs.ENTRY_CARD_SKELETON,
+ ),
+ ).toBeNull();
+ },
+ { timeout: 15000 },
+ );
+ expect(
+ screen.queryByTestId(MarketInsightsSelectorsIDs.ENTRY_CARD),
+ ).toBeNull();
+ });
+
+ it('does not show entry card when market insights feature flag is off', async () => {
+ setupMarketInsightsEngineMock(MOCK_PERPS_MARKET_INSIGHTS_REPORT);
+
+ renderAssetOverviewMarketInsightsStack(
+ [
+ {
+ name: Routes.MARKET_INSIGHTS.VIEW,
+ Component:
+ MarketInsightsView as unknown as React.ComponentType,
+ },
+ ],
+ { state: buildTokenDetailsMarketInsightsState(false) },
+ );
+
+ expect(
+ await screen.findByTestId(TokenOverviewSelectorsIDs.CONTAINER),
+ ).toBeOnTheScreen();
+
+ await waitFor(() => {
+ expect(
+ screen.queryByTestId(MarketInsightsSelectorsIDs.ENTRY_CARD_SKELETON),
+ ).toBeNull();
+ });
+ expect(
+ screen.queryByTestId(MarketInsightsSelectorsIDs.ENTRY_CARD),
+ ).toBeNull();
+ });
+
+ it('shows entry card when report exists and opens Market Insights on press', async () => {
+ setupMarketInsightsEngineMock(MOCK_PERPS_MARKET_INSIGHTS_REPORT);
+
+ renderAssetOverviewMarketInsightsStack(
+ [
+ {
+ name: Routes.MARKET_INSIGHTS.VIEW,
+ Component:
+ MarketInsightsView as unknown as React.ComponentType,
+ },
+ { name: Routes.BRIDGE.ROOT },
+ { name: Routes.RAMP.TOKEN_SELECTION },
+ ],
+ { state: buildTokenDetailsMarketInsightsState(true) },
+ );
+
+ const entryCard = await screen.findByTestId(
+ MarketInsightsSelectorsIDs.ENTRY_CARD,
+ {},
+ { timeout: 15000 },
+ );
+ fireEvent.press(entryCard);
+
+ expect(
+ await screen.findByTestId(MarketInsightsSelectorsIDs.VIEW_CONTAINER),
+ ).toBeOnTheScreen();
+ });
+ },
+);
diff --git a/app/components/Views/Notifications/NotificationsView.view.test.tsx b/app/components/Views/Notifications/NotificationsView.view.test.tsx
new file mode 100644
index 00000000000..be9c1faae83
--- /dev/null
+++ b/app/components/Views/Notifications/NotificationsView.view.test.tsx
@@ -0,0 +1,169 @@
+import '../../../../tests/component-view/mocks';
+import React from 'react';
+import { FlatList, Text, View } from 'react-native';
+import { useNavigation, useRoute } from '@react-navigation/native';
+import { fireEvent, waitFor } from '@testing-library/react-native';
+
+import { renderScreenWithRoutes } from '../../../../tests/component-view/render';
+import { describeForPlatforms } from '../../../../tests/component-view/platform';
+import {
+ buildNotificationsState,
+ MOCK_NOTIFICATIONS,
+} from '../../../../tests/component-view/presets/notifications';
+import {
+ MOCK_FEATURE_ANNOUNCEMENT_NOTIFICATIONS,
+ MOCK_ON_CHAIN_NOTIFICATIONS,
+} from '../../../components/UI/Notification/__mocks__/mock_notifications';
+import Routes from '../../../constants/navigation/Routes';
+import { NotificationMenuViewSelectorsIDs } from './NotificationMenuView.testIds';
+import { NotificationsViewSelectorsIDs } from './NotificationsView.testIds';
+import NotificationsView from './';
+
+/**
+ * Component-view coverage for smoke `enable-notifications-after-onboarding`.
+ *
+ * Smoke spec: tests/smoke/notifications/enable-notifications-after-onboarding.spec.ts
+ *
+ * Notifications are seeded into Redux via `buildNotificationsState` (controllers
+ * + remote feature flag), mirroring what the smoke E2E gets via
+ * `mockNotificationServices` mockttp responses — no nock needed at the view
+ * layer. `IS_TEST=true` (set at config-load time in `jest.config.view.js`)
+ * flips `isNotificationsFeatureEnabled` on without mocking the config module.
+ */
+
+const NOTIFICATIONS_DETAILS_PROBE_TEST_ID = 'notifications-details-probe';
+const NOTIFICATIONS_DETAILS_PROBE_ID_TEST_ID = 'notifications-details-probe-id';
+const NOTIFICATIONS_DETAILS_BACK_TEST_ID =
+ 'notifications-details-probe-back-button';
+
+/**
+ * Lightweight `NotificationsDetails` stand-in. Avoids needing the real
+ * `NotificationComponentState` machinery (block-explorer footers, asset rows…)
+ * unrelated to this flow. Mirrors the back-navigation contract that
+ * `NotificationDetailsView.tapOnBackButton()` exercises in the smoke spec.
+ */
+function NotificationsDetailsProbe() {
+ const route = useRoute();
+ const navigation = useNavigation();
+ const params = route.params as { notification?: { id?: string } } | undefined;
+ return (
+
+ Notification Details
+
+ {params?.notification?.id ?? ''}
+
+ navigation.goBack()}
+ >
+ Back
+
+
+ );
+}
+
+function renderNotificationsScreen(
+ notifications: typeof MOCK_NOTIFICATIONS = MOCK_NOTIFICATIONS,
+) {
+ return renderScreenWithRoutes(
+ NotificationsView as unknown as React.ComponentType,
+ { name: 'NotificationsView' },
+ [
+ {
+ name: Routes.NOTIFICATIONS.DETAILS,
+ Component: NotificationsDetailsProbe,
+ },
+ ],
+ { state: buildNotificationsState({ notifications }) },
+ );
+}
+
+describeForPlatforms('Notifications view (list + details flow)', () => {
+ /**
+ * The smoke spec inspects the rendered list of notifications. In jest,
+ * `FlatList` never receives layout metrics so it only renders the first row
+ * — instead of fighting virtualization we assert on the FlatList `data`
+ * prop, which is the same source the device-rendered list reads from.
+ */
+ it('exposes the full seeded notifications list to the FlatList data source', () => {
+ const result = renderNotificationsScreen();
+ const flatList = result.UNSAFE_getByType(FlatList);
+
+ expect(
+ result.getByTestId(NotificationsViewSelectorsIDs.NOTIFICATIONS_CONTAINER),
+ ).toBeOnTheScreen();
+
+ const data = (flatList.props as { data?: typeof MOCK_NOTIFICATIONS }).data;
+ expect(data).toHaveLength(MOCK_NOTIFICATIONS.length);
+
+ const seededIds = new Set(MOCK_NOTIFICATIONS.map((n) => n.id));
+ data?.forEach((n) => {
+ expect(seededIds.has(n.id)).toBe(true);
+ });
+ });
+
+ /**
+ * Seed only the feature announcement so it's the first (and only) row in
+ * the FlatList's initial render window — proves the same tap → details →
+ * back path the smoke spec asserts on, without depending on virtualization.
+ */
+ it('opens details for a feature announcement and returns on back', async () => {
+ const featureAnnouncement = MOCK_FEATURE_ANNOUNCEMENT_NOTIFICATIONS[0];
+ const result = renderNotificationsScreen([featureAnnouncement]);
+
+ fireEvent.press(
+ await result.findByTestId(
+ NotificationMenuViewSelectorsIDs.ITEM(featureAnnouncement.id),
+ ),
+ );
+
+ await waitFor(() => {
+ expect(
+ result.getByTestId(NOTIFICATIONS_DETAILS_PROBE_TEST_ID),
+ ).toBeOnTheScreen();
+ });
+ expect(
+ result.getByTestId(NOTIFICATIONS_DETAILS_PROBE_ID_TEST_ID),
+ ).toHaveTextContent(featureAnnouncement.id);
+
+ fireEvent.press(result.getByTestId(NOTIFICATIONS_DETAILS_BACK_TEST_ID));
+
+ await waitFor(() => {
+ expect(
+ result.getByTestId(
+ NotificationsViewSelectorsIDs.NOTIFICATIONS_CONTAINER,
+ ),
+ ).toBeOnTheScreen();
+ });
+ });
+
+ it('opens details for a wallet notification and returns on back', async () => {
+ const walletNotification = MOCK_ON_CHAIN_NOTIFICATIONS[0];
+ const result = renderNotificationsScreen([walletNotification]);
+
+ fireEvent.press(
+ await result.findByTestId(
+ NotificationMenuViewSelectorsIDs.ITEM(walletNotification.id),
+ ),
+ );
+
+ await waitFor(() => {
+ expect(
+ result.getByTestId(NOTIFICATIONS_DETAILS_PROBE_TEST_ID),
+ ).toBeOnTheScreen();
+ });
+ expect(
+ result.getByTestId(NOTIFICATIONS_DETAILS_PROBE_ID_TEST_ID),
+ ).toHaveTextContent(walletNotification.id);
+
+ fireEvent.press(result.getByTestId(NOTIFICATIONS_DETAILS_BACK_TEST_ID));
+
+ await waitFor(() => {
+ expect(
+ result.getByTestId(
+ NotificationsViewSelectorsIDs.NOTIFICATIONS_CONTAINER,
+ ),
+ ).toBeOnTheScreen();
+ });
+ });
+});
diff --git a/app/components/Views/Settings/NotificationsSettings/NotificationsSettings.view.test.tsx b/app/components/Views/Settings/NotificationsSettings/NotificationsSettings.view.test.tsx
new file mode 100644
index 00000000000..df67c1d30e5
--- /dev/null
+++ b/app/components/Views/Settings/NotificationsSettings/NotificationsSettings.view.test.tsx
@@ -0,0 +1,200 @@
+import '../../../../../tests/component-view/mocks';
+import React from 'react';
+import { fireEvent, waitFor } from '@testing-library/react-native';
+
+import { renderComponentViewScreen } from '../../../../../tests/component-view/render';
+import { describeForPlatforms } from '../../../../../tests/component-view/platform';
+import {
+ buildNotificationsState,
+ NOTIFICATIONS_ACCOUNT_ADDRESS,
+} from '../../../../../tests/component-view/presets/notifications';
+import NotificationsSettings from './';
+import {
+ NotificationSettingsViewSelectorsIDs,
+ NotificationSettingsViewSelectorsText,
+} from './NotificationSettingsView.testIds';
+import Engine from '../../../../core/Engine';
+
+/**
+ * Component-view coverage for smoke `notification-settings-flow`.
+ *
+ * Smoke spec: tests/smoke/notifications/notification-settings-flow.spec.ts
+ *
+ * No hooks, selectors or services are mocked here — the per-account toggle
+ * resolves from real `AccountTreeController` + `AccountsController` state
+ * seeded by `buildNotificationsState`. The feature flag check resolves true
+ * via `IS_TEST=true` (set at config-load time in `jest.config.view.js`).
+ */
+
+function renderSettings(
+ stateOverrides?: Parameters[0],
+) {
+ return renderComponentViewScreen(
+ NotificationsSettings as unknown as React.ComponentType,
+ { name: 'NotificationsSettings' },
+ { state: buildNotificationsState(stateOverrides) },
+ { isFullScreenModal: false },
+ );
+}
+
+describeForPlatforms('Notifications settings (toggles + visibility)', () => {
+ it('renders all sub-toggles when notifications are enabled', async () => {
+ const { getByTestId, findByText } = renderSettings();
+
+ expect(
+ getByTestId(NotificationSettingsViewSelectorsIDs.NOTIFICATIONS_TOGGLE),
+ ).toBeOnTheScreen();
+
+ expect(
+ await waitFor(() =>
+ getByTestId(
+ NotificationSettingsViewSelectorsIDs.PUSH_NOTIFICATIONS_TOGGLE,
+ ),
+ ),
+ ).toBeOnTheScreen();
+
+ expect(
+ getByTestId(
+ NotificationSettingsViewSelectorsIDs.FEATURE_ANNOUNCEMENTS_TOGGLE,
+ ),
+ ).toBeOnTheScreen();
+
+ expect(
+ await findByText(
+ NotificationSettingsViewSelectorsText.ACCOUNT_ACTIVITY_SECTION,
+ ),
+ ).toBeOnTheScreen();
+
+ expect(
+ getByTestId(
+ NotificationSettingsViewSelectorsIDs.ACCOUNT_NOTIFICATION_TOGGLE(
+ NOTIFICATIONS_ACCOUNT_ADDRESS,
+ ),
+ ),
+ ).toBeOnTheScreen();
+ });
+
+ it('hides push, feature announcements and account section when main toggle is off', async () => {
+ const { getByTestId, queryByTestId, queryByText } = renderSettings({
+ notificationsEnabled: false,
+ });
+
+ expect(
+ getByTestId(NotificationSettingsViewSelectorsIDs.NOTIFICATIONS_TOGGLE),
+ ).toBeOnTheScreen();
+
+ await waitFor(() => {
+ expect(
+ queryByTestId(
+ NotificationSettingsViewSelectorsIDs.PUSH_NOTIFICATIONS_TOGGLE,
+ ),
+ ).toBeNull();
+ });
+ expect(
+ queryByTestId(
+ NotificationSettingsViewSelectorsIDs.FEATURE_ANNOUNCEMENTS_TOGGLE,
+ ),
+ ).toBeNull();
+ expect(
+ queryByText(
+ NotificationSettingsViewSelectorsText.ACCOUNT_ACTIVITY_SECTION,
+ ),
+ ).toBeNull();
+ });
+
+ it('invokes the disable controller path when the main toggle is pressed (on -> off)', async () => {
+ const disableSpy = jest
+ .spyOn(
+ Engine.context.NotificationServicesController as unknown as {
+ disableNotificationServices: () => Promise;
+ },
+ 'disableNotificationServices',
+ )
+ .mockResolvedValue(undefined);
+
+ try {
+ const { getByTestId } = renderSettings();
+
+ fireEvent(
+ getByTestId(NotificationSettingsViewSelectorsIDs.NOTIFICATIONS_TOGGLE),
+ 'onChange',
+ { nativeEvent: { value: false } },
+ );
+
+ await waitFor(() => {
+ expect(disableSpy).toHaveBeenCalled();
+ });
+ } finally {
+ disableSpy.mockRestore();
+ }
+ });
+
+ it('invokes setFeatureAnnouncementsEnabled(false) when the feature announcements toggle is pressed', async () => {
+ const toggleSpy = jest
+ .spyOn(
+ Engine.context.NotificationServicesController as unknown as {
+ setFeatureAnnouncementsEnabled: (val: boolean) => Promise;
+ },
+ 'setFeatureAnnouncementsEnabled',
+ )
+ .mockResolvedValue(undefined);
+
+ try {
+ const { getByTestId } = renderSettings();
+
+ fireEvent(
+ getByTestId(
+ NotificationSettingsViewSelectorsIDs.FEATURE_ANNOUNCEMENTS_TOGGLE,
+ ),
+ 'onChange',
+ { nativeEvent: { value: false } },
+ );
+
+ await waitFor(() => {
+ expect(toggleSpy).toHaveBeenCalledWith(false);
+ });
+ } finally {
+ toggleSpy.mockRestore();
+ }
+ });
+
+ /**
+ * The per-account toggle's initial state comes from
+ * `Engine.NotificationServicesController.checkAccountsPresence`, which our
+ * Engine stub resolves to `{}` by default → toggle starts OFF. Pressing it
+ * therefore calls `enableAccounts` (off → on); the inverse direction is
+ * symmetrical. We assert the wiring through the press, not the direction.
+ */
+ it('invokes enableAccounts with the account address when the per-account toggle is pressed', async () => {
+ const enableAccountsSpy = jest
+ .spyOn(
+ Engine.context.NotificationServicesController as unknown as {
+ enableAccounts: (addresses: string[]) => Promise;
+ },
+ 'enableAccounts',
+ )
+ .mockResolvedValue(undefined);
+
+ try {
+ const { getByTestId } = renderSettings();
+
+ fireEvent(
+ getByTestId(
+ NotificationSettingsViewSelectorsIDs.ACCOUNT_NOTIFICATION_TOGGLE(
+ NOTIFICATIONS_ACCOUNT_ADDRESS,
+ ),
+ ),
+ 'onChange',
+ { nativeEvent: { value: true } },
+ );
+
+ await waitFor(() => {
+ expect(enableAccountsSpy).toHaveBeenCalledWith([
+ NOTIFICATIONS_ACCOUNT_ADDRESS,
+ ]);
+ });
+ } finally {
+ enableAccountsSpy.mockRestore();
+ }
+ });
+});
diff --git a/app/components/Views/confirmations/ConfirmationView.testIds.ts b/app/components/Views/confirmations/ConfirmationView.testIds.ts
index 2b147feb4bc..eedd5f5a585 100644
--- a/app/components/Views/confirmations/ConfirmationView.testIds.ts
+++ b/app/components/Views/confirmations/ConfirmationView.testIds.ts
@@ -55,6 +55,10 @@ export const ConfirmationFooterSelectorIDs = {
CONFIRM_BUTTON: 'confirm-button',
} as const;
+export const ConfirmationLoaderSelectorIDs = {
+ TRANSFER: 'confirm-loader-transfer',
+} as const;
+
export const ConfirmAlertModalSelectorsIDs = {
CONFIRM_ALERT_CHECKBOX: 'confirm-alert-checkbox',
CONFIRM_ALERT_BUTTON: 'confirm-alert-confirm-button',
diff --git a/app/components/Views/confirmations/components/activity/eip-7702-sponsored-relay-api-failure.view.test.tsx b/app/components/Views/confirmations/components/activity/eip-7702-sponsored-relay-api-failure.view.test.tsx
new file mode 100644
index 00000000000..d8e9986e54c
--- /dev/null
+++ b/app/components/Views/confirmations/components/activity/eip-7702-sponsored-relay-api-failure.view.test.tsx
@@ -0,0 +1,107 @@
+/**
+ * Component-view coverage for smoke `gas-fee-tokens-eip-7702-sponsored`:
+ * (1) Activity / transaction details show Failed from `TransactionMeta.status` (not a hardcoded StatusText prop);
+ * (2) Review-step "Paid by MetaMask" gas row when sponsorship is allowed.
+ */
+import '../../../../../../tests/component-view/mocks';
+import React from 'react';
+import { cloneDeep } from 'lodash';
+import { fireEvent, waitFor } from '@testing-library/react-native';
+import { TransactionStatus } from '@metamask/transaction-controller';
+import { ConfirmationRowComponentIDs } from '../../ConfirmationView.testIds';
+import { ConfirmationContextProvider } from '../../context/confirmation-context';
+import GasFeesDetailsRow from '../rows/transactions/gas-fee-details-row/gas-fee-details-row';
+import { stakingDepositConfirmationState } from '../../../../../util/test/confirm-data-helpers';
+import { renderComponentViewScreen } from '../../../../../../tests/component-view/render';
+import { describeForPlatforms } from '../../../../../../tests/component-view/platform';
+import {
+ clearSentinelNetworksMocks,
+ setupSentinelNetworksRelayEnabledMock,
+} from '../../../../../../tests/component-view/api-mocking/sentinel-networks';
+import { TransactionDetailsStatusRow } from './transaction-details-status-row/transaction-details-status-row';
+import { STATUS_ICON_TOOLTIP_OPEN_BUTTON_TEST_ID } from '../status-icon/status-icon.testIds';
+import { strings } from '../../../../../../locales/i18n';
+
+const STAKING_TX_ID = '699ca2f0-e459-11ef-b6f6-d182277cf5e1';
+
+function relayFailedActivityState() {
+ const state = cloneDeep(stakingDepositConfirmationState);
+ const tx = state.engine.backgroundState.TransactionController.transactions[0];
+ tx.status = TransactionStatus.failed;
+ tx.error = {
+ name: 'JsonRpcError',
+ message: 'Relay submission failed',
+ };
+ return state;
+}
+
+/**
+ * Review step from the same smoke spec: simulation marks sponsorship; network fee row
+ * shows "Paid by MetaMask" (matches `RowComponents.NetworkFeePaidByMetaMask` / E2E wait
+ * before Confirm).
+ */
+function SponsoredGasFeeRowHarness() {
+ return (
+
+
+
+ );
+}
+
+describeForPlatforms(
+ 'EIP-7702 sponsored send — relay API failure (activity / details status)',
+ () => {
+ it('maps failed transaction in state to Failed label and error tooltip (transaction details)', async () => {
+ const { getByText, getByTestId } = renderComponentViewScreen(
+ TransactionDetailsStatusRow,
+ { name: 'Eip7702RelayApiFailureActivity' },
+ { state: relayFailedActivityState() },
+ { transactionId: STAKING_TX_ID },
+ );
+
+ await waitFor(() =>
+ expect(getByText(strings('transaction.failed'))).toBeOnTheScreen(),
+ );
+
+ fireEvent.press(getByTestId(STATUS_ICON_TOOLTIP_OPEN_BUTTON_TEST_ID));
+
+ expect(getByText('Relay submission failed')).toBeOnTheScreen();
+ });
+ },
+);
+
+describeForPlatforms('EIP-7702 sponsored send — review (network fee)', () => {
+ const sponsoredGasFeeState = () => {
+ const state = cloneDeep(stakingDepositConfirmationState);
+ const tx = state.engine.backgroundState.TransactionController
+ .transactions[0] as { isGasFeeSponsored?: boolean };
+ tx.isGasFeeSponsored = true;
+ return state;
+ };
+
+ beforeEach(() => {
+ setupSentinelNetworksRelayEnabledMock();
+ });
+
+ afterEach(() => {
+ clearSentinelNetworksMocks();
+ });
+
+ it('shows Paid by MetaMask on the gas row when sponsorship is allowed (smoke review step)', async () => {
+ const { getByTestId, getByText } = renderComponentViewScreen(
+ SponsoredGasFeeRowHarness,
+ { name: 'Eip7702SponsoredGasFee' },
+ { state: sponsoredGasFeeState() },
+ );
+
+ await waitFor(
+ () => {
+ expect(
+ getByTestId(ConfirmationRowComponentIDs.PAID_BY_METAMASK),
+ ).toBeOnTheScreen();
+ },
+ { timeout: 8000 },
+ );
+ expect(getByText('Paid by MetaMask')).toBeOnTheScreen();
+ });
+});
diff --git a/app/components/Views/confirmations/components/alert-banner/alert-system-security-failed.view.test.tsx b/app/components/Views/confirmations/components/alert-banner/alert-system-security-failed.view.test.tsx
new file mode 100644
index 00000000000..0fa96b13b6a
--- /dev/null
+++ b/app/components/Views/confirmations/components/alert-banner/alert-system-security-failed.view.test.tsx
@@ -0,0 +1,88 @@
+import '../../../../../../tests/component-view/mocks';
+import React from 'react';
+import { merge } from 'lodash';
+import { fireEvent, waitFor } from '@testing-library/react-native';
+
+import { renderComponentViewScreen } from '../../../../../../tests/component-view/render';
+import { describeForPlatforms } from '../../../../../../tests/component-view/platform';
+import { typedSignV1ConfirmationState } from '../../../../../util/test/confirm-data-helpers';
+import {
+ ConfirmationTopSheetSelectorsIDs,
+ ConfirmationTopSheetSelectorsText,
+} from '../../ConfirmationView.testIds';
+import { AlertsContextProvider } from '../../context/alert-system-context';
+import useBlockaidAlerts from '../../hooks/alerts/useBlockaidAlerts';
+import AlertBanner from './alert-banner';
+import { Reason, ResultType } from '../blockaid-banner/BlockaidBanner.types';
+
+/** Matches `messageParams.requestId` on the typed-sign fixture (ppom / security alert key). */
+const TYPED_SIGN_SECURITY_ALERT_KEY = '2453610887';
+
+/** No `req`/`chainId` so `BlockaidAlertContent` skips gzip (view env has no native gzip). */
+const securityValidationFailedResponse = {
+ result_type: ResultType.Failed,
+ reason: Reason.failed,
+ features: [] as string[],
+};
+
+/**
+ * Blockaid alerts only (same source as the first slice of `useConfirmationAlerts` for this state).
+ * Skips transaction-alert hooks that need QueryClient, hardware wallet, etc.
+ */
+function BlockaidAlertBannerHarness() {
+ const alerts = useBlockaidAlerts();
+ return (
+
+
+
+ );
+}
+
+describeForPlatforms('Alert system (signatures)', () => {
+ /**
+ * Smoke `alert-system`: security validation API error shows the redesigned banner
+ * and failed-state copy (no signing success path).
+ */
+ it('shows security alert when validation request fails', async () => {
+ const state = merge({}, typedSignV1ConfirmationState, {
+ securityAlerts: {
+ alerts: {
+ [TYPED_SIGN_SECURITY_ALERT_KEY]: securityValidationFailedResponse,
+ },
+ },
+ engine: {
+ backgroundState: {
+ PreferencesController: {
+ securityAlertsEnabled: true,
+ },
+ },
+ },
+ });
+
+ const { getByTestId, getByText } = renderComponentViewScreen(
+ BlockaidAlertBannerHarness,
+ { name: 'SecurityValidationFailed' },
+ { state },
+ );
+
+ const bannerTestId =
+ ConfirmationTopSheetSelectorsIDs.SECURITY_ALERT_BANNER_REDESIGNED;
+
+ await waitFor(() => {
+ expect(getByTestId(bannerTestId)).toBeOnTheScreen();
+ });
+
+ expect(
+ getByText(ConfirmationTopSheetSelectorsText.BANNER_FAILED_TITLE),
+ ).toBeOnTheScreen();
+ expect(
+ getByText(ConfirmationTopSheetSelectorsText.BANNER_FAILED_DESCRIPTION),
+ ).toBeOnTheScreen();
+
+ fireEvent.press(getByTestId(bannerTestId));
+
+ await waitFor(() => {
+ expect(getByTestId(bannerTestId)).toBeOnTheScreen();
+ });
+ });
+});
diff --git a/app/components/Views/confirmations/components/alert-banner/alert-system-siwe-inline-mismatch.view.test.tsx b/app/components/Views/confirmations/components/alert-banner/alert-system-siwe-inline-mismatch.view.test.tsx
new file mode 100644
index 00000000000..3d4523ab038
--- /dev/null
+++ b/app/components/Views/confirmations/components/alert-banner/alert-system-siwe-inline-mismatch.view.test.tsx
@@ -0,0 +1,133 @@
+import '../../../../../../tests/component-view/mocks';
+import React from 'react';
+import { fireEvent, waitFor } from '@testing-library/react-native';
+import { merge } from 'lodash';
+
+import ExtendedKeyringTypes from '../../../../../constants/keyringTypes';
+import { HardwareWalletProvider } from '../../../../../core/HardwareWallet/HardwareWalletProvider';
+import { renderComponentViewScreen } from '../../../../../../tests/component-view/render';
+import { describeForPlatforms } from '../../../../../../tests/component-view/platform';
+import { siweSignatureConfirmationState } from '../../../../../util/test/confirm-data-helpers';
+import {
+ AlertModalSelectorsIDs,
+ AlertModalSelectorsText,
+ AlertTypeIDs,
+ ConfirmationFooterSelectorIDs,
+ ConfirmAlertModalSelectorsIDs,
+} from '../../ConfirmationView.testIds';
+import { AlertsContextProvider } from '../../context/alert-system-context';
+import { ConfirmationContextProvider } from '../../context/confirmation-context';
+import { QRHardwareContextProvider } from '../../context/qr-hardware-context';
+import useDomainMismatchAlerts from '../../hooks/alerts/useDomainMismatchAlerts';
+import { NetworkAndOriginRow } from '../rows/transactions/network-and-origin-row/network-and-origin-row';
+import { Footer } from '../footer/footer';
+
+/**
+ * `meta.url` origin must differ from the SIWE message domain so
+ * `isValidSIWEOrigin` fails (same idea as SIWE “bad domain” E2E).
+ */
+const SIWE_BAD_DOMAIN_STATE = merge({}, siweSignatureConfirmationState, {
+ engine: {
+ backgroundState: {
+ PreferencesController: {
+ securityAlertsEnabled: true,
+ },
+ ApprovalController: {
+ pendingApprovals: {
+ '72424261-e22f-11ef-8e59-bf627a5d8354': {
+ requestData: {
+ meta: {
+ url: 'https://malicious.example.test/fake-dapp/',
+ },
+ },
+ },
+ },
+ },
+ },
+ },
+});
+
+const SIWE_SIGNER_ADDRESS =
+ '0x8eeee1781fd885ff5ddef7789486676961873d12' as const;
+
+function seedEngineKeyringWithSiweSigner(): void {
+ const engineMock = jest.requireMock(
+ '../../../../../../app/core/Engine',
+ ) as unknown as {
+ default: {
+ context: {
+ KeyringController: { state: { keyrings: unknown[] } };
+ };
+ };
+ };
+ engineMock.default.context.KeyringController.state.keyrings = [
+ {
+ type: ExtendedKeyringTypes.hd,
+ accounts: [SIWE_SIGNER_ADDRESS],
+ },
+ ];
+}
+
+function SiweDomainMismatchInlineFlowHarness() {
+ const alerts = useDomainMismatchAlerts();
+ return (
+
+
+
+
+
+
+
+
+
+
+ );
+}
+
+describeForPlatforms('Alert system (SIWE inline)', () => {
+ /**
+ * Smoke `alert-system` Inline: domain mismatch → inline icon → alert modal →
+ * acknowledge → Confirm → second confirm modal → acknowledge (no broadcast assertion).
+ */
+ beforeEach(() => {
+ seedEngineKeyringWithSiweSigner();
+ });
+
+ it('inline mismatch: open modal, acknowledge, then confirm modals', async () => {
+ const { getByTestId, getByText } = renderComponentViewScreen(
+ SiweDomainMismatchInlineFlowHarness,
+ { name: 'SiweInlineMismatch' },
+ { state: SIWE_BAD_DOMAIN_STATE },
+ );
+
+ expect(getByTestId(AlertTypeIDs.INLINE_ALERT)).toBeOnTheScreen();
+
+ fireEvent.press(getByTestId(AlertTypeIDs.INLINE_ALERT));
+
+ await waitFor(() =>
+ expect(
+ getByText(AlertModalSelectorsText.ALERT_ORIGIN_MISMATCH_TITLE),
+ ).toBeOnTheScreen(),
+ );
+
+ fireEvent.press(getByTestId(AlertModalSelectorsIDs.ALERT_MODAL_CHECKBOX));
+ fireEvent.press(
+ getByTestId(AlertModalSelectorsIDs.ALERT_MODAL_ACKNOWLEDGE_BUTTON),
+ );
+
+ fireEvent.press(getByTestId(ConfirmationFooterSelectorIDs.CONFIRM_BUTTON));
+
+ await waitFor(() =>
+ expect(
+ getByTestId(ConfirmAlertModalSelectorsIDs.CONFIRM_ALERT_MODAL),
+ ).toBeOnTheScreen(),
+ );
+
+ fireEvent.press(
+ getByTestId(ConfirmAlertModalSelectorsIDs.CONFIRM_ALERT_CHECKBOX),
+ );
+ fireEvent.press(
+ getByTestId(ConfirmAlertModalSelectorsIDs.CONFIRM_ALERT_BUTTON),
+ );
+ });
+});
diff --git a/app/components/Views/confirmations/components/alert-banner/alert-system-typed-sign-blockaid.view.test.tsx b/app/components/Views/confirmations/components/alert-banner/alert-system-typed-sign-blockaid.view.test.tsx
new file mode 100644
index 00000000000..db5ae143fa3
--- /dev/null
+++ b/app/components/Views/confirmations/components/alert-banner/alert-system-typed-sign-blockaid.view.test.tsx
@@ -0,0 +1,197 @@
+/**
+ * Component-view coverage for smoke `alert-system` typed-sign security API cases:
+ * (1) Benign validation response -> no redesigned security banner (matches E2E mock
+ * `SECURITY_ALERTS_BENIGN_RESPONSE`).
+ * (2) Malicious `malicious_domain` -> banner + confirm alert modal flow (matches E2E
+ * acknowledge path; no live security API).
+ */
+import '../../../../../../tests/component-view/mocks';
+import React from 'react';
+import { merge } from 'lodash';
+import { fireEvent, waitFor } from '@testing-library/react-native';
+
+import ExtendedKeyringTypes from '../../../../../constants/keyringTypes';
+import { HardwareWalletProvider } from '../../../../../core/HardwareWallet/HardwareWalletProvider';
+import { renderComponentViewScreen } from '../../../../../../tests/component-view/render';
+import { describeForPlatforms } from '../../../../../../tests/component-view/platform';
+import { typedSignV1ConfirmationState } from '../../../../../util/test/confirm-data-helpers';
+import {
+ ConfirmationFooterSelectorIDs,
+ ConfirmationTopSheetSelectorsIDs,
+ ConfirmationTopSheetSelectorsText,
+ ConfirmAlertModalSelectorsIDs,
+} from '../../ConfirmationView.testIds';
+import { AlertsContextProvider } from '../../context/alert-system-context';
+import { ConfirmationContextProvider } from '../../context/confirmation-context';
+import { QRHardwareContextProvider } from '../../context/qr-hardware-context';
+import useBlockaidAlerts from '../../hooks/alerts/useBlockaidAlerts';
+import AlertBanner from './alert-banner';
+import { Footer } from '../footer/footer';
+import { Reason, ResultType } from '../blockaid-banner/BlockaidBanner.types';
+
+/** Matches `messageParams.requestId` on the typed-sign fixture (security alert map key). */
+const TYPED_SIGN_SECURITY_ALERT_KEY = '2453610887';
+
+const TYPED_SIGN_SIGNER_ADDRESS =
+ '0x935e73edb9ff52e23bac7f7e043a1ecd06d05477' as const;
+
+/** No `req`/`chainId` so `BlockaidAlertContent` skips gzip (view env). */
+const benignSecurityApiResponse = {
+ result_type: ResultType.Benign,
+ reason: Reason.notApplicable,
+ features: [] as string[],
+};
+
+const maliciousDomainSecurityResponse = {
+ result_type: ResultType.Malicious,
+ reason: Reason.maliciousDomain,
+ description: `You're interacting with a malicious domain. If you approve this request, you might lose your assets.`,
+ features: [] as string[],
+};
+
+function seedEngineKeyringWithTypedSignSigner(): void {
+ const engineMock = jest.requireMock(
+ '../../../../../../app/core/Engine',
+ ) as unknown as {
+ default: {
+ context: {
+ KeyringController: { state: { keyrings: unknown[] } };
+ };
+ };
+ };
+ engineMock.default.context.KeyringController.state.keyrings = [
+ {
+ type: ExtendedKeyringTypes.hd,
+ accounts: [TYPED_SIGN_SIGNER_ADDRESS],
+ },
+ ];
+}
+
+function TypedSignMaliciousBlockaidFlowHarness() {
+ const alerts = useBlockaidAlerts();
+ return (
+
+
+
+
+
+
+
+
+
+
+ );
+}
+
+describeForPlatforms(
+ 'Alert system (typed sign — benign security response)',
+ () => {
+ beforeEach(() => {
+ seedEngineKeyringWithTypedSignSigner();
+ });
+
+ it('lets user confirm without security banner or confirm-alert modal when validation is Benign', async () => {
+ const state = merge({}, typedSignV1ConfirmationState, {
+ securityAlerts: {
+ alerts: {
+ [TYPED_SIGN_SECURITY_ALERT_KEY]: benignSecurityApiResponse,
+ },
+ },
+ engine: {
+ backgroundState: {
+ PreferencesController: {
+ securityAlertsEnabled: true,
+ },
+ },
+ },
+ });
+
+ const { findByTestId, queryByTestId } = renderComponentViewScreen(
+ TypedSignMaliciousBlockaidFlowHarness,
+ { name: 'TypedSignBenignBlockaid' },
+ { state },
+ );
+
+ const confirmButton = await findByTestId(
+ ConfirmationFooterSelectorIDs.CONFIRM_BUTTON,
+ );
+
+ await waitFor(() => {
+ expect(
+ queryByTestId(
+ ConfirmationTopSheetSelectorsIDs.SECURITY_ALERT_BANNER_REDESIGNED,
+ ),
+ ).toBeNull();
+ });
+
+ fireEvent.press(confirmButton);
+
+ await waitFor(() => {
+ expect(
+ queryByTestId(ConfirmAlertModalSelectorsIDs.CONFIRM_ALERT_MODAL),
+ ).toBeNull();
+ });
+
+ expect(
+ queryByTestId(
+ ConfirmationTopSheetSelectorsIDs.SECURITY_ALERT_BANNER_REDESIGNED,
+ ),
+ ).toBeNull();
+ });
+ },
+);
+
+describeForPlatforms('Alert system (typed sign — malicious blockaid)', () => {
+ beforeEach(() => {
+ seedEngineKeyringWithTypedSignSigner();
+ });
+
+ it('shows malicious banner then confirm alert modal after Confirm (malicious_domain)', async () => {
+ const state = merge({}, typedSignV1ConfirmationState, {
+ securityAlerts: {
+ alerts: {
+ [TYPED_SIGN_SECURITY_ALERT_KEY]: maliciousDomainSecurityResponse,
+ },
+ },
+ engine: {
+ backgroundState: {
+ PreferencesController: {
+ securityAlertsEnabled: true,
+ },
+ },
+ },
+ });
+
+ const { getByTestId, getByText } = renderComponentViewScreen(
+ TypedSignMaliciousBlockaidFlowHarness,
+ { name: 'TypedSignMaliciousBlockaid' },
+ { state },
+ );
+
+ const bannerTestId =
+ ConfirmationTopSheetSelectorsIDs.SECURITY_ALERT_BANNER_REDESIGNED;
+
+ await waitFor(() => {
+ expect(getByTestId(bannerTestId)).toBeOnTheScreen();
+ });
+
+ expect(
+ getByText(ConfirmationTopSheetSelectorsText.BANNER_MALICIOUS_TITLE),
+ ).toBeOnTheScreen();
+
+ fireEvent.press(getByTestId(ConfirmationFooterSelectorIDs.CONFIRM_BUTTON));
+
+ await waitFor(() =>
+ expect(
+ getByTestId(ConfirmAlertModalSelectorsIDs.CONFIRM_ALERT_MODAL),
+ ).toBeOnTheScreen(),
+ );
+
+ fireEvent.press(
+ getByTestId(ConfirmAlertModalSelectorsIDs.CONFIRM_ALERT_CHECKBOX),
+ );
+ fireEvent.press(
+ getByTestId(ConfirmAlertModalSelectorsIDs.CONFIRM_ALERT_BUTTON),
+ );
+ });
+});
diff --git a/app/components/Views/confirmations/components/rows/origin-row/origin-row.test.tsx b/app/components/Views/confirmations/components/rows/origin-row/origin-row.test.tsx
index 84d7ca2d967..ee80171fcf8 100644
--- a/app/components/Views/confirmations/components/rows/origin-row/origin-row.test.tsx
+++ b/app/components/Views/confirmations/components/rows/origin-row/origin-row.test.tsx
@@ -22,7 +22,7 @@ describe('InfoRowOrigin', () => {
state: siweSignatureConfirmationState,
});
expect(getByText('Signing in with')).toBeDefined();
- expect(getByText('0x935E7...05477')).toBeDefined();
+ expect(getByText('0x8Eeee...73D12')).toBeDefined();
});
it('does not render origin for wallet originated approvals', async () => {
diff --git a/app/components/Views/confirmations/components/send/RedesignedSendView.testIds.ts b/app/components/Views/confirmations/components/send/RedesignedSendView.testIds.ts
index 7d047936d7b..9e7b77a3337 100644
--- a/app/components/Views/confirmations/components/send/RedesignedSendView.testIds.ts
+++ b/app/components/Views/confirmations/components/send/RedesignedSendView.testIds.ts
@@ -1,6 +1,7 @@
export const RedesignedSendViewSelectorsIDs = {
SEND_AMOUNT: 'send_amount',
EDIT_AMOUNT_KEYBOARD: 'edit-amount-keyboard',
+ PERCENTAGE_BUTTON_50: 'percentage-button-50',
PERCENTAGE_BUTTON_100: 'percentage-button-100',
RECIPIENT_ADDRESS_INPUT: 'recipient-address-input',
REVIEW_BUTTON: 'review-button',
diff --git a/app/components/Views/confirmations/components/send/send.non-evm.view.test.tsx b/app/components/Views/confirmations/components/send/send.non-evm.view.test.tsx
new file mode 100644
index 00000000000..ab0bb5c503b
--- /dev/null
+++ b/app/components/Views/confirmations/components/send/send.non-evm.view.test.tsx
@@ -0,0 +1,480 @@
+import '../../../../../../tests/component-view/mocks';
+import React from 'react';
+import { fireEvent, screen, waitFor } from '@testing-library/react-native';
+import { renderScreenWithRoutes } from '../../../../../../tests/component-view/render';
+import {
+ buildAddressBookOverridesWithEvmContact,
+ buildNonEvmSendAccountsOverrides,
+ buildTronSendFixture,
+ NON_EVM_BTC_ACCOUNT_ID,
+ NON_EVM_SOLANA_ACCOUNT_ID,
+ sendViewOverrides,
+} from '../../../../../../tests/component-view/presets/send';
+import { initialStateWallet } from '../../../../../../tests/component-view/presets/wallet';
+import { describeForPlatforms } from '../../../../../../tests/component-view/platform';
+import Routes from '../../../../../constants/navigation/Routes';
+import {
+ getRecipientRowTestId,
+ getSelectedRecipientTestId,
+ RedesignedSendViewSelectorsIDs,
+} from './RedesignedSendView.testIds';
+import { Send } from './send';
+import { HardwareWalletProvider } from '../../../../../core/HardwareWallet/HardwareWalletProvider';
+import {
+ clearSnapControllerHandleRequestMock,
+ setupSnapControllerHandleRequestMock,
+} from '../../../../../../tests/component-view/helpers/snapRequests';
+
+const BTC_MAINNET_CHAIN_ID = 'bip122:000000000019d6689c085ae165831e93' as const;
+
+const SOLANA_MAINNET_CHAIN_ID =
+ 'solana:5eykt4UsFv8P8NJdTREpY1vzqKqZKvdp' as const;
+
+const TRON_MAINNET_CHAIN_ID = 'tron:728126428' as const;
+
+const NON_EVM_NETWORK_OVERRIDE = {
+ engine: {
+ backgroundState: {
+ MultichainNetworkController: {
+ isEvmSelected: false,
+ },
+ },
+ },
+} as unknown as Record;
+
+/** Native SOL (smoke `send-solana-token` / non-EVM send tests). */
+const SOLANA_NATIVE_ASSET = {
+ address: `${SOLANA_MAINNET_CHAIN_ID}/native`,
+ chainId: SOLANA_MAINNET_CHAIN_ID,
+ symbol: 'SOL',
+ decimals: 9,
+ balance: '100',
+ rawBalance: '100',
+};
+
+const SOLANA_NATIVE_ASSET_SEND_FIVE = {
+ ...SOLANA_NATIVE_ASSET,
+ balance: '10',
+ rawBalance: '10000000000',
+ accountId: NON_EVM_SOLANA_ACCOUNT_ID,
+};
+
+const BTC_NATIVE_ASSET_SEND_FIVE = {
+ address: `${BTC_MAINNET_CHAIN_ID}/slip44:0`,
+ chainId: BTC_MAINNET_CHAIN_ID,
+ symbol: 'BTC',
+ decimals: 8,
+ balance: '10',
+ rawBalance: '0x3B9ACA00', // 10 BTC in sats
+ isNative: true,
+ accountId: NON_EVM_BTC_ACCOUNT_ID,
+};
+
+/** Native BTC with 1 satoshi on-chain balance — any full-unit input exceeds it. */
+const MINIMAL_BTC_BALANCE_ASSET = {
+ address: `${BTC_MAINNET_CHAIN_ID}/slip44:0`,
+ chainId: BTC_MAINNET_CHAIN_ID,
+ symbol: 'BTC',
+ decimals: 8,
+ balance: '0.00000001',
+ rawBalance: '0x1',
+ isNative: true,
+};
+
+const TRON_NATIVE_ASSET_SEND_FIVE = {
+ address: `${TRON_MAINNET_CHAIN_ID}/native`,
+ chainId: TRON_MAINNET_CHAIN_ID,
+ symbol: 'TRX',
+ decimals: 6,
+ balance: '10',
+ rawBalance: '0x989680', // 10 TRX in sun
+ accountId: 'tron-acc-1',
+};
+
+const VALID_SOLANA_RECIPIENT = '11111111111111111111111111111111';
+const VALID_BTC_RECIPIENT = 'bc1qxy2kgdygjrsqtzq2n0yrf2493p83kkfjhx0wlh';
+
+function SendFlowWithHardwareWalletProvider() {
+ return (
+
+
+
+ );
+}
+
+describeForPlatforms('Send (Non-EVM)', () => {
+ let snapHandleRequestSpy: jest.SpyInstance;
+
+ beforeEach(() => {
+ snapHandleRequestSpy = setupSnapControllerHandleRequestMock({
+ onAmountInputResponse: { valid: true, errors: [] },
+ confirmSendResponse: { valid: true },
+ });
+ });
+
+ afterEach(() => {
+ clearSnapControllerHandleRequestMock();
+ });
+
+ // Regression test for Issue #22789 and related to #23251
+ // TRON send flow: selecting a destination account must move the flow forward
+ // (previously it stayed on the recipient list and did not navigate).
+ it('TRON send: selecting destination account updates selection', async () => {
+ const { tronOverrides, recipientAddresses } = buildTronSendFixture();
+
+ const state = initialStateWallet().withOverrides(tronOverrides).build();
+
+ const tronAsset = {
+ address: `${TRON_MAINNET_CHAIN_ID}/native`,
+ chainId: TRON_MAINNET_CHAIN_ID,
+ symbol: 'TRX',
+ decimals: 6,
+ balance: '100',
+ rawBalance: '0x64',
+ accountId: 'tron-acc-1',
+ };
+
+ const { getByTestId, getByRole, findByTestId } = renderScreenWithRoutes(
+ Send as unknown as React.ComponentType,
+ { name: Routes.SEND.DEFAULT },
+ [],
+ { state },
+ { screen: Routes.SEND.AMOUNT, params: { asset: tronAsset } },
+ );
+
+ expect(
+ getByTestId(RedesignedSendViewSelectorsIDs.SEND_AMOUNT),
+ ).toBeOnTheScreen();
+
+ fireEvent.press(
+ getByTestId(RedesignedSendViewSelectorsIDs.PERCENTAGE_BUTTON_100),
+ );
+ fireEvent.press(getByRole('button', { name: 'Continue' }));
+
+ expect(
+ await findByTestId(
+ RedesignedSendViewSelectorsIDs.RECIPIENT_ADDRESS_INPUT,
+ ),
+ ).toBeOnTheScreen();
+
+ const recipientItem = await findByTestId(
+ getRecipientRowTestId(recipientAddresses[0]),
+ {},
+ { timeout: 10000 },
+ );
+ fireEvent.press(recipientItem);
+
+ expect(
+ await findByTestId(
+ getSelectedRecipientTestId(recipientAddresses[0]),
+ {},
+ { timeout: 10000 },
+ ),
+ ).toBeOnTheScreen();
+ });
+
+ /**
+ * Regression test for issue #22205
+ * EVM contacts must not appear in non-EVM (e.g. Solana, BTC) send flow Recipient screen.
+ * Only contacts for the current chain/protocol should be shown.
+ */
+ it('Solana send Recipient screen does not show EVM contacts', async () => {
+ const EVM_CONTACT_ADDRESS = '0x1234567890123456789012345678901234567890';
+
+ const addressBookOverrides =
+ buildAddressBookOverridesWithEvmContact(EVM_CONTACT_ADDRESS);
+
+ const state = initialStateWallet()
+ .withOverrides(sendViewOverrides)
+ .withOverrides(addressBookOverrides)
+ .build();
+
+ const { findByTestId, queryByTestId } = renderScreenWithRoutes(
+ Send as unknown as React.ComponentType,
+ { name: Routes.SEND.DEFAULT },
+ [],
+ { state },
+ {
+ screen: Routes.SEND.RECIPIENT,
+ params: { asset: SOLANA_NATIVE_ASSET },
+ },
+ );
+
+ expect(
+ await findByTestId(
+ RedesignedSendViewSelectorsIDs.RECIPIENT_ADDRESS_INPUT,
+ ),
+ ).toBeOnTheScreen();
+
+ const evmContactRow = queryByTestId(
+ getRecipientRowTestId(EVM_CONTACT_ADDRESS),
+ );
+ expect(evmContactRow).not.toBeOnTheScreen();
+ });
+
+ it('Bitcoin send Recipient screen does not show EVM contacts', async () => {
+ const EVM_CONTACT_ADDRESS = '0x1234567890123456789012345678901234567890';
+
+ const addressBookOverrides =
+ buildAddressBookOverridesWithEvmContact(EVM_CONTACT_ADDRESS);
+
+ const state = initialStateWallet()
+ .withOverrides(sendViewOverrides)
+ .withOverrides(addressBookOverrides)
+ .build();
+
+ const { findByTestId, queryByTestId } = renderScreenWithRoutes(
+ Send as unknown as React.ComponentType,
+ { name: Routes.SEND.DEFAULT },
+ [],
+ { state },
+ {
+ screen: Routes.SEND.RECIPIENT,
+ params: { asset: MINIMAL_BTC_BALANCE_ASSET },
+ },
+ );
+
+ expect(
+ await findByTestId(
+ RedesignedSendViewSelectorsIDs.RECIPIENT_ADDRESS_INPUT,
+ ),
+ ).toBeOnTheScreen();
+
+ const evmContactRow = queryByTestId(
+ getRecipientRowTestId(EVM_CONTACT_ADDRESS),
+ );
+ expect(evmContactRow).not.toBeOnTheScreen();
+ });
+
+ it('Solana native: Amount screen shows Send title and SOL', async () => {
+ const state = initialStateWallet()
+ .withOverrides(sendViewOverrides)
+ .withOverrides(NON_EVM_NETWORK_OVERRIDE)
+ .build();
+
+ const { getByTestId } = renderScreenWithRoutes(
+ Send as unknown as React.ComponentType,
+ { name: Routes.SEND.DEFAULT },
+ [],
+ { state },
+ { screen: Routes.SEND.AMOUNT, params: { asset: SOLANA_NATIVE_ASSET } },
+ );
+
+ expect(
+ getByTestId(RedesignedSendViewSelectorsIDs.SEND_AMOUNT),
+ ).toBeOnTheScreen();
+ // Title and/or other copy may repeat "Send" — mirror E2E "text displayed" checks
+ expect(screen.getAllByText('Send').length).toBeGreaterThanOrEqual(1);
+ expect(screen.getAllByText('SOL').length).toBeGreaterThanOrEqual(1);
+ });
+
+ it('Solana native: digit 5 submits and opens transfer confirmation route', async () => {
+ const state = initialStateWallet()
+ .withOverrides(sendViewOverrides)
+ .withOverrides(NON_EVM_NETWORK_OVERRIDE)
+ .withOverrides(buildNonEvmSendAccountsOverrides())
+ .build();
+
+ const { getByTestId, getByText, getByRole, findByTestId } =
+ renderScreenWithRoutes(
+ SendFlowWithHardwareWalletProvider as unknown as React.ComponentType,
+ { name: Routes.SEND.DEFAULT },
+ [{ name: Routes.TRANSACTIONS_VIEW }],
+ { state },
+ {
+ screen: Routes.SEND.AMOUNT,
+ params: { asset: SOLANA_NATIVE_ASSET_SEND_FIVE },
+ },
+ );
+
+ expect(
+ getByTestId(RedesignedSendViewSelectorsIDs.SEND_AMOUNT),
+ ).toBeOnTheScreen();
+
+ fireEvent.press(getByText('5'));
+
+ const continueButton = getByRole('button', { name: 'Continue' });
+ await waitFor(() => expect(continueButton).toBeEnabled(), {
+ timeout: 5000,
+ });
+ fireEvent.press(continueButton);
+
+ const recipientInput = await findByTestId(
+ RedesignedSendViewSelectorsIDs.RECIPIENT_ADDRESS_INPUT,
+ {},
+ { timeout: 5000 },
+ );
+ fireEvent.changeText(recipientInput, VALID_SOLANA_RECIPIENT);
+
+ const reviewButton = await findByTestId(
+ RedesignedSendViewSelectorsIDs.REVIEW_BUTTON,
+ {},
+ { timeout: 5000 },
+ );
+ await waitFor(() => expect(reviewButton).toBeEnabled(), {
+ timeout: 5000,
+ });
+ fireEvent.press(reviewButton);
+
+ await waitFor(() => {
+ expect(snapHandleRequestSpy).toHaveBeenCalledWith(
+ 'SnapController:handleRequest',
+ expect.objectContaining({
+ request: expect.objectContaining({ method: 'confirmSend' }),
+ }),
+ );
+ });
+
+ expect(await findByTestId('route-TransactionsView')).toBeOnTheScreen();
+ }, 20000);
+
+ it('Bitcoin Amount: exceeding balance shows Insufficient funds', async () => {
+ const state = initialStateWallet()
+ .withOverrides(sendViewOverrides)
+ .withOverrides(NON_EVM_NETWORK_OVERRIDE)
+ .build();
+
+ const { getByTestId, getByText, findByRole } = renderScreenWithRoutes(
+ Send as unknown as React.ComponentType,
+ { name: Routes.SEND.DEFAULT },
+ [],
+ { state },
+ {
+ screen: Routes.SEND.AMOUNT,
+ params: { asset: MINIMAL_BTC_BALANCE_ASSET },
+ },
+ );
+
+ expect(
+ getByTestId(RedesignedSendViewSelectorsIDs.SEND_AMOUNT),
+ ).toBeOnTheScreen();
+
+ // One satoshi balance; keypad "1" is 1 BTC — far exceeds balance
+ fireEvent.press(getByText('1'));
+
+ expect(
+ await findByRole(
+ 'button',
+ { name: 'Insufficient funds' },
+ { timeout: 5000 },
+ ),
+ ).toBeOnTheScreen();
+ });
+
+ it('Bitcoin native: digit 5 submits and opens transfer confirmation route', async () => {
+ const state = initialStateWallet()
+ .withOverrides(sendViewOverrides)
+ .withOverrides(NON_EVM_NETWORK_OVERRIDE)
+ .withOverrides(buildNonEvmSendAccountsOverrides())
+ .build();
+
+ const { getByTestId, getByText, getByRole, findByTestId } =
+ renderScreenWithRoutes(
+ SendFlowWithHardwareWalletProvider as unknown as React.ComponentType,
+ { name: Routes.SEND.DEFAULT },
+ [{ name: Routes.TRANSACTIONS_VIEW }],
+ { state },
+ {
+ screen: Routes.SEND.AMOUNT,
+ params: { asset: BTC_NATIVE_ASSET_SEND_FIVE },
+ },
+ );
+
+ expect(
+ getByTestId(RedesignedSendViewSelectorsIDs.SEND_AMOUNT),
+ ).toBeOnTheScreen();
+
+ fireEvent.press(getByText('5'));
+
+ const continueButton = getByRole('button', { name: 'Continue' });
+ await waitFor(() => expect(continueButton).toBeEnabled(), {
+ timeout: 5000,
+ });
+ fireEvent.press(continueButton);
+
+ const recipientInput = await findByTestId(
+ RedesignedSendViewSelectorsIDs.RECIPIENT_ADDRESS_INPUT,
+ {},
+ { timeout: 5000 },
+ );
+ fireEvent.changeText(recipientInput, VALID_BTC_RECIPIENT);
+
+ const reviewButton = await findByTestId(
+ RedesignedSendViewSelectorsIDs.REVIEW_BUTTON,
+ {},
+ { timeout: 5000 },
+ );
+ await waitFor(() => expect(reviewButton).toBeEnabled(), {
+ timeout: 5000,
+ });
+ fireEvent.press(reviewButton);
+
+ await waitFor(() => {
+ expect(snapHandleRequestSpy).toHaveBeenCalledWith(
+ 'SnapController:handleRequest',
+ expect.objectContaining({
+ request: expect.objectContaining({ method: 'confirmSend' }),
+ }),
+ );
+ });
+
+ expect(await findByTestId('route-TransactionsView')).toBeOnTheScreen();
+ }, 20000);
+
+ it('TRON native: digit 5 submits and opens transfer confirmation route', async () => {
+ const { tronOverrides, recipientAddresses } = buildTronSendFixture();
+ const state = initialStateWallet().withOverrides(tronOverrides).build();
+
+ const { getByTestId, getByText, getByRole, findByTestId } =
+ renderScreenWithRoutes(
+ SendFlowWithHardwareWalletProvider as unknown as React.ComponentType,
+ { name: Routes.SEND.DEFAULT },
+ [{ name: Routes.TRANSACTIONS_VIEW }],
+ { state },
+ {
+ screen: Routes.SEND.AMOUNT,
+ params: { asset: TRON_NATIVE_ASSET_SEND_FIVE },
+ },
+ );
+
+ expect(
+ getByTestId(RedesignedSendViewSelectorsIDs.SEND_AMOUNT),
+ ).toBeOnTheScreen();
+
+ fireEvent.press(getByText('5'));
+
+ const continueButton = getByRole('button', { name: 'Continue' });
+ await waitFor(() => expect(continueButton).toBeEnabled(), {
+ timeout: 5000,
+ });
+ fireEvent.press(continueButton);
+
+ const recipientInput = await findByTestId(
+ RedesignedSendViewSelectorsIDs.RECIPIENT_ADDRESS_INPUT,
+ {},
+ { timeout: 5000 },
+ );
+ fireEvent.changeText(recipientInput, recipientAddresses[0]);
+
+ const reviewButton = await findByTestId(
+ RedesignedSendViewSelectorsIDs.REVIEW_BUTTON,
+ {},
+ { timeout: 5000 },
+ );
+ await waitFor(() => expect(reviewButton).toBeEnabled(), {
+ timeout: 5000,
+ });
+ fireEvent.press(reviewButton);
+
+ await waitFor(() => {
+ expect(snapHandleRequestSpy).toHaveBeenCalledWith(
+ 'SnapController:handleRequest',
+ expect.objectContaining({
+ request: expect.objectContaining({ method: 'confirmSend' }),
+ }),
+ );
+ });
+
+ expect(await findByTestId('route-TransactionsView')).toBeOnTheScreen();
+ }, 20000);
+});
diff --git a/app/components/Views/confirmations/components/send/send.view.test.tsx b/app/components/Views/confirmations/components/send/send.view.test.tsx
index a70bb4c7f41..67330dcc3b4 100644
--- a/app/components/Views/confirmations/components/send/send.view.test.tsx
+++ b/app/components/Views/confirmations/components/send/send.view.test.tsx
@@ -2,24 +2,22 @@ import '../../../../../../tests/component-view/mocks';
import React from 'react';
import { fireEvent, screen, waitFor } from '@testing-library/react-native';
import { renderScreenWithRoutes } from '../../../../../../tests/component-view/render';
-import {
- buildAddressBookOverridesWithEvmContact,
- buildTronSendFixture,
- sendViewOverrides,
-} from '../../../../../../tests/component-view/presets/send';
+import { sendViewOverrides } from '../../../../../../tests/component-view/presets/send';
import { initialStateWallet } from '../../../../../../tests/component-view/presets/wallet';
import { describeForPlatforms } from '../../../../../../tests/component-view/platform';
import Routes from '../../../../../constants/navigation/Routes';
import { TokenStandard } from '../../types/token';
+import { HardwareWalletProvider } from '../../../../../core/HardwareWallet/HardwareWalletProvider';
import {
getNftRowTestId,
getRecipientAvatarTestId,
getRecipientRowTestId,
- getSelectedRecipientTestId,
RedesignedSendViewSelectorsIDs,
} from './RedesignedSendView.testIds';
import { Send } from './send';
import { SendAlertModalSelectorIDs } from './send-alert-modal/send-alert-modal.testIds';
+import { strings } from '../../../../../../locales/i18n';
+import { ConfirmationLoaderSelectorIDs } from '../../ConfirmationView.testIds';
/** A minimal ETH asset with 2 ETH balance, suitable for EVM send tests. */
const EVM_ETH_ASSET = {
@@ -31,117 +29,39 @@ const EVM_ETH_ASSET = {
rawBalance: '0x1BC16D674EC80000', // 2 ETH
};
+/**
+ * Native ETH with high balance so keypad "5" is within available funds (smoke
+ * `send-native-token` uses ~10 ETH from balances API).
+ */
+const EVM_NATIVE_ETH_ASSET_SEND_FIVE = {
+ ...EVM_ETH_ASSET,
+ balance: '10',
+ rawBalance: '0x8AC7230489E80000', // 10 ETH
+};
+
const VALID_EVM_RECIPIENT = '0x0000000000000000000000000000000000000002';
const TOKEN_CONTRACT_ADDRESS = '0x0000000000000000000000000000000000000003';
-describeForPlatforms('Send', () => {
- describe('Non-EVM', () => {
- // Regression test for Issue #22789 and related to #23251
- // TRON send flow: selecting a destination account must move the flow forward
- // (previously it stayed on the recipient list and did not navigate).
- it('TRON send: selecting destination account updates selection', async () => {
- const { tronOverrides, recipientAddresses } = buildTronSendFixture();
-
- const state = initialStateWallet().withOverrides(tronOverrides).build();
-
- const TRON_MAINNET_CHAIN_ID = 'tron:728126428';
-
- const tronAsset = {
- address: `${TRON_MAINNET_CHAIN_ID}/native`,
- chainId: TRON_MAINNET_CHAIN_ID,
- symbol: 'TRX',
- decimals: 6,
- balance: '100',
- rawBalance: '0x64',
- accountId: 'tron-acc-1',
- };
-
- const { getByTestId, getByRole, findByTestId } = renderScreenWithRoutes(
- Send as unknown as React.ComponentType,
- { name: Routes.SEND.DEFAULT },
- [],
- { state },
- { screen: Routes.SEND.AMOUNT, params: { asset: tronAsset } },
- );
-
- expect(
- getByTestId(RedesignedSendViewSelectorsIDs.SEND_AMOUNT),
- ).toBeOnTheScreen();
-
- fireEvent.press(
- getByTestId(RedesignedSendViewSelectorsIDs.PERCENTAGE_BUTTON_100),
- );
- fireEvent.press(getByRole('button', { name: 'Continue' }));
-
- expect(
- await findByTestId(
- RedesignedSendViewSelectorsIDs.RECIPIENT_ADDRESS_INPUT,
- ),
- ).toBeOnTheScreen();
-
- const recipientItem = await findByTestId(
- getRecipientRowTestId(recipientAddresses[0]),
- {},
- { timeout: 10000 },
- );
- fireEvent.press(recipientItem);
-
- expect(
- await findByTestId(
- getSelectedRecipientTestId(recipientAddresses[0]),
- {},
- { timeout: 10000 },
- ),
- ).toBeOnTheScreen();
- });
-
- /**
- * Regression test for issue #22205
- * EVM contacts must not appear in non-EVM (e.g. Solana, BTC) send flow Recipient screen.
- * Only contacts for the current chain/protocol should be shown.
- */
- it('Solana send Recipient screen does not show EVM contacts', async () => {
- const SOLANA_CHAIN_ID = 'solana:5eykt4UsFv8P8NJdTREpY1vzqKqZKvdp';
- const EVM_CONTACT_ADDRESS = '0x1234567890123456789012345678901234567890';
-
- const addressBookOverrides =
- buildAddressBookOverridesWithEvmContact(EVM_CONTACT_ADDRESS);
-
- const solanaAsset = {
- address: `${SOLANA_CHAIN_ID}/native`,
- chainId: SOLANA_CHAIN_ID,
- symbol: 'SOL',
- decimals: 9,
- balance: '100',
- rawBalance: '100',
- };
-
- const state = initialStateWallet()
- .withOverrides(sendViewOverrides)
- .withOverrides(addressBookOverrides)
- .build();
-
- const { findByTestId, queryByTestId } = renderScreenWithRoutes(
- Send as unknown as React.ComponentType,
- { name: Routes.SEND.DEFAULT },
- [],
- { state },
- { screen: Routes.SEND.RECIPIENT, params: { asset: solanaAsset } },
- );
-
- expect(
- await findByTestId(
- RedesignedSendViewSelectorsIDs.RECIPIENT_ADDRESS_INPUT,
- ),
- ).toBeOnTheScreen();
+/** Mainnet USDC (6 decimals), high balance — mirrors smoke ERC-20 send E2E fixture. */
+const EVM_USDC_ASSET = {
+ address: '0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48',
+ chainId: '0x1',
+ symbol: 'USDC',
+ decimals: 6,
+ balance: '10000',
+ rawBalance: '0x2540BE400', // 10_000 * 10^6
+ standard: TokenStandard.ERC20,
+};
- const evmContactRow = queryByTestId(
- getRecipientRowTestId(EVM_CONTACT_ADDRESS),
- );
- expect(evmContactRow).not.toBeOnTheScreen();
- });
- });
+function SendFlowWithHardwareWalletProvider() {
+ return (
+
+
+
+ );
+}
+describeForPlatforms('Send', () => {
describe('ERC-721', () => {
/**
* Regression test for issue #12317
@@ -271,8 +191,9 @@ describeForPlatforms('Send', () => {
});
/**
- * Core EVM send happy path: Amount → Continue → Recipient.
- * Typing a valid address must enable the Review button.
+ * Core EVM native ETH path: Amount (100%) → Continue → Recipient → Review.
+ * Uses the same `Amount` / `useAmountValidation` stack as ERC-20; native vs
+ * token is determined by asset (zero address vs contract).
*/
it('ETH: Amount → Continue → Recipient, valid address enables Review', async () => {
const state = initialStateWallet()
@@ -313,6 +234,175 @@ describeForPlatforms('Send', () => {
});
});
+ /**
+ * Native ETH keyed amount mirrors the smoke `send-native-token` happy path:
+ * 5 ETH -> Continue -> Recipient -> Review -> transfer confirmation route.
+ */
+ it('Native ETH: digit 5 submits and opens transfer confirmation route', async () => {
+ const state = initialStateWallet()
+ .withOverrides(sendViewOverrides)
+ .build();
+
+ const engineMock = jest.requireMock(
+ '../../../../../../app/core/Engine',
+ ) as unknown as {
+ default: {
+ context: {
+ TransactionController: { addTransaction: jest.Mock };
+ };
+ };
+ };
+ const addTransactionSpy =
+ engineMock.default.context.TransactionController.addTransaction;
+ addTransactionSpy.mockClear();
+
+ const { getByTestId, getByText, getByRole, findByTestId } =
+ renderScreenWithRoutes(
+ SendFlowWithHardwareWalletProvider as unknown as React.ComponentType,
+ { name: Routes.SEND.DEFAULT },
+ [],
+ { state },
+ {
+ screen: Routes.SEND.AMOUNT,
+ params: { asset: EVM_NATIVE_ETH_ASSET_SEND_FIVE },
+ },
+ );
+
+ expect(
+ getByTestId(RedesignedSendViewSelectorsIDs.SEND_AMOUNT),
+ ).toBeOnTheScreen();
+
+ fireEvent.press(getByText('5'));
+ fireEvent.press(getByRole('button', { name: 'Continue' }));
+
+ const recipientInput = await findByTestId(
+ RedesignedSendViewSelectorsIDs.RECIPIENT_ADDRESS_INPUT,
+ {},
+ { timeout: 5000 },
+ );
+ fireEvent.changeText(recipientInput, VALID_EVM_RECIPIENT);
+
+ const reviewButton = await findByTestId(
+ RedesignedSendViewSelectorsIDs.REVIEW_BUTTON,
+ {},
+ { timeout: 5000 },
+ );
+
+ await waitFor(() => expect(reviewButton).toBeEnabled(), {
+ timeout: 5000,
+ });
+ fireEvent.press(reviewButton);
+
+ await waitFor(() => {
+ expect(addTransactionSpy).toHaveBeenCalledTimes(1);
+ });
+
+ expect(
+ await findByTestId(ConfirmationLoaderSelectorIDs.TRANSFER),
+ ).toBeOnTheScreen();
+ });
+
+ /**
+ * Covers smoke `send-erc20-token`: 50% of balance through Recipient / Review
+ * into the transfer confirmation route.
+ */
+ it('ERC-20 USDC: 50% submits and opens transfer confirmation route', async () => {
+ const state = initialStateWallet()
+ .withOverrides(sendViewOverrides)
+ .build();
+
+ const engineMock = jest.requireMock(
+ '../../../../../../app/core/Engine',
+ ) as unknown as {
+ default: {
+ context: {
+ TransactionController: { addTransaction: jest.Mock };
+ };
+ };
+ };
+ const addTransactionSpy =
+ engineMock.default.context.TransactionController.addTransaction;
+ addTransactionSpy.mockClear();
+
+ const { getByTestId, getByRole, findByTestId } = renderScreenWithRoutes(
+ SendFlowWithHardwareWalletProvider as unknown as React.ComponentType,
+ { name: Routes.SEND.DEFAULT },
+ [],
+ { state },
+ { screen: Routes.SEND.AMOUNT, params: { asset: EVM_USDC_ASSET } },
+ );
+
+ expect(
+ getByTestId(RedesignedSendViewSelectorsIDs.SEND_AMOUNT),
+ ).toBeOnTheScreen();
+
+ fireEvent.press(
+ getByTestId(RedesignedSendViewSelectorsIDs.PERCENTAGE_BUTTON_50),
+ );
+ fireEvent.press(getByRole('button', { name: 'Continue' }));
+
+ const recipientInput = await findByTestId(
+ RedesignedSendViewSelectorsIDs.RECIPIENT_ADDRESS_INPUT,
+ {},
+ { timeout: 5000 },
+ );
+ fireEvent.changeText(recipientInput, VALID_EVM_RECIPIENT);
+
+ const reviewButton = await findByTestId(
+ RedesignedSendViewSelectorsIDs.REVIEW_BUTTON,
+ {},
+ { timeout: 5000 },
+ );
+
+ await waitFor(() => expect(reviewButton).toBeEnabled(), {
+ timeout: 5000,
+ });
+ fireEvent.press(reviewButton);
+
+ await waitFor(() => {
+ expect(addTransactionSpy).toHaveBeenCalledTimes(1);
+ });
+
+ expect(
+ await findByTestId(ConfirmationLoaderSelectorIDs.TRANSFER),
+ ).toBeOnTheScreen();
+ });
+
+ /**
+ * Send Max ETH → Continue → Recipient
+ */
+ it('Send Max → Continue → Recipient screen', async () => {
+ const state = initialStateWallet()
+ .withOverrides(sendViewOverrides)
+ .build();
+
+ const { getByTestId, getByRole, findByTestId } = renderScreenWithRoutes(
+ Send as unknown as React.ComponentType,
+ { name: Routes.SEND.DEFAULT },
+ [],
+ { state },
+ {
+ screen: Routes.SEND.AMOUNT,
+ params: { asset: EVM_NATIVE_ETH_ASSET_SEND_FIVE },
+ },
+ );
+
+ expect(
+ getByTestId(RedesignedSendViewSelectorsIDs.SEND_AMOUNT),
+ ).toBeOnTheScreen();
+
+ fireEvent.press(getByRole('button', { name: 'Max' }));
+ fireEvent.press(getByRole('button', { name: 'Continue' }));
+
+ expect(
+ await findByTestId(
+ RedesignedSendViewSelectorsIDs.RECIPIENT_ADDRESS_INPUT,
+ {},
+ { timeout: 5000 },
+ ),
+ ).toBeOnTheScreen();
+ });
+
/**
* Typing an invalid address (not a valid hex, ENS, or non-EVM address)
* must disable the Review button and show an error label.
@@ -345,6 +435,13 @@ describeForPlatforms('Send', () => {
await waitFor(() => expect(reviewButton).toBeDisabled(), {
timeout: 5000,
});
+ expect(
+ await screen.findByRole(
+ 'button',
+ { name: strings('send.invalid_address') },
+ { timeout: 5000 },
+ ),
+ ).toBeOnTheScreen();
});
/**
diff --git a/app/components/Views/confirmations/components/status-icon/status-icon.testIds.ts b/app/components/Views/confirmations/components/status-icon/status-icon.testIds.ts
new file mode 100644
index 00000000000..5f20caf74b0
--- /dev/null
+++ b/app/components/Views/confirmations/components/status-icon/status-icon.testIds.ts
@@ -0,0 +1,3 @@
+export const STATUS_ICON_TOOLTIP_TEST_ID = 'status-tooltip';
+
+export const STATUS_ICON_TOOLTIP_OPEN_BUTTON_TEST_ID = `${STATUS_ICON_TOOLTIP_TEST_ID}-open-btn`;
diff --git a/app/components/Views/confirmations/components/status-icon/status-icon.tsx b/app/components/Views/confirmations/components/status-icon/status-icon.tsx
index ce752275950..d61a4ffc2cb 100644
--- a/app/components/Views/confirmations/components/status-icon/status-icon.tsx
+++ b/app/components/Views/confirmations/components/status-icon/status-icon.tsx
@@ -8,6 +8,7 @@ import { ButtonIconSizes } from '../../../../../component-library/components/But
import { useStyles } from '../../../../hooks/useStyles';
import Tooltip from '../UI/Tooltip';
import styleSheet from './status-icon.styles';
+import { STATUS_ICON_TOOLTIP_TEST_ID } from './status-icon.testIds';
export type Severity = 'success' | 'error' | 'warning';
@@ -29,7 +30,7 @@ export function StatusIcon({
iconName={iconName}
iconSize={ButtonIconSizes.Md}
iconStyle={styles.tooltipIcon}
- tooltipTestId="status-tooltip"
+ tooltipTestId={STATUS_ICON_TOOLTIP_TEST_ID}
content={tooltip}
/>
);
diff --git a/app/util/test/confirm-data-helpers.ts b/app/util/test/confirm-data-helpers.ts
index 5569f7376fe..98b5254ef35 100644
--- a/app/util/test/confirm-data-helpers.ts
+++ b/app/util/test/confirm-data-helpers.ts
@@ -110,6 +110,7 @@ export const siweSignSignatureRequest = {
...personalSignSignatureRequest,
messageParams: {
...personalSignSignatureRequest.messageParams,
+ from: '0x8eeee1781fd885ff5ddef7789486676961873d12',
data: '0x6d6574616d61736b2e6769746875622e696f2077616e747320796f7520746f207369676e20696e207769746820796f757220457468657265756d206163636f756e743a0a3078386565656531373831666438383566663564646566373738393438363637363936313837336431320a0a492061636365707420746865204d6574614d61736b205465726d73206f6620536572766963653a2068747470733a2f2f636f6d6d756e6974792e6d6574616d61736b2e696f2f746f730a0a5552493a2068747470733a2f2f6d6574616d61736b2e6769746875622e696f0a56657273696f6e3a20310a436861696e2049443a20310a4e6f6e63653a2033323839313735370a4973737565642041743a20323032312d30392d33305431363a32353a32342e3030305a',
siwe: {
isSIWEMessage: true,
diff --git a/jest.config.view.js b/jest.config.view.js
index 761a146c3ff..02dc996d16e 100644
--- a/jest.config.view.js
+++ b/jest.config.view.js
@@ -1,6 +1,25 @@
/* eslint-disable import-x/no-commonjs */
const baseConfig = require('./jest.config.js');
+// View tests are integration-level and exercise the real production code path
+// for the feature under test. Some app-level feature gates are guarded by
+// build-time env vars that babel inlines at compile time
+// (`babel-plugin-transform-inline-environment-variables` in babel.config.js),
+// so `process.env.X` reads in app code are substituted with whatever value the
+// var has when babel runs — runtime mutation from `mocks.ts` or
+// `setupFilesAfterEnv` is too late. Setting `IS_TEST=true` here, at
+// config-load time before babel compiles any test file, opts view tests into
+// the same `isE2E` shortcut smoke tests already use; this avoids mocking the
+// guarded config module (forbidden in CV).
+//
+// TODO: remove this once `app/util/notifications/constants/config.ts:
+// isNotificationsFeatureEnabled` (and any future similar gate) is refactored
+// to drop its `process.env.MM_NOTIFICATIONS_UI_ENABLED` dependency in favour
+// of a single state-driven (`RemoteFeatureFlagController`) check — at which
+// point view tests can seed the flag through Redux state like every other
+// feature already does.
+process.env.IS_TEST = 'true';
+
module.exports = {
...baseConfig,
setupFilesAfterEnv: ['/app/util/test/testSetupView.js'],
diff --git a/tests/component-view/api-mocking/sentinel-networks.ts b/tests/component-view/api-mocking/sentinel-networks.ts
new file mode 100644
index 00000000000..764ce14e3bf
--- /dev/null
+++ b/tests/component-view/api-mocking/sentinel-networks.ts
@@ -0,0 +1,58 @@
+/**
+ * Sentinel `/networks` API mock for component view tests that need relay / gasless
+ * eligibility (e.g. EIP-7702 sponsored fee row). Matches
+ * `getAllSentinelNetworkFlags` → `https://tx-sentinel-ethereum-mainnet.api.cx.metamask.io/networks`.
+ */
+
+// eslint-disable-next-line import-x/no-extraneous-dependencies
+import nock from 'nock';
+
+import { clearSentinelNetworkCache } from '../../../app/util/transactions/sentinel-api';
+import {
+ clearAllNockMocks,
+ disableNetConnect,
+ teardownNock,
+} from './nockHelpers';
+
+const SENTINEL_ETHEREUM_MAINNET_ORIGIN =
+ 'https://tx-sentinel-ethereum-mainnet.api.cx.metamask.io';
+
+/** Minimal shape consumed by `getSentinelNetworkFlags` / relay URL resolution. */
+const ETHEREUM_MAINNET_FLAGS = {
+ name: 'Ethereum Mainnet',
+ group: 'ethereum',
+ chainID: 1,
+ nativeCurrency: {
+ name: 'Ether',
+ symbol: 'ETH',
+ decimals: 18,
+ },
+ network: 'ethereum-mainnet',
+ explorer: 'https://etherscan.io',
+ confirmations: true,
+ smartTransactions: false,
+ relayTransactions: true,
+ hidden: false,
+ sendBundle: false,
+};
+
+/**
+ * Enables relay + 7702 gasless checks for chain id `0x1` (decimal key `"1"`).
+ * Call `clearSentinelNetworkCache()` before setup so the in-memory sentinel cache refetches.
+ */
+export function setupSentinelNetworksRelayEnabledMock(): void {
+ clearSentinelNetworkCache();
+ disableNetConnect();
+ nock(SENTINEL_ETHEREUM_MAINNET_ORIGIN)
+ .get('/networks')
+ .reply(200, {
+ '1': ETHEREUM_MAINNET_FLAGS,
+ })
+ .persist();
+}
+
+export function clearSentinelNetworksMocks(): void {
+ clearAllNockMocks();
+ clearSentinelNetworkCache();
+ teardownNock();
+}
diff --git a/tests/component-view/fixtures/perpsMarketInsights.ts b/tests/component-view/fixtures/perpsMarketInsights.ts
index df2eaca55bd..2a7fb1c5a78 100644
--- a/tests/component-view/fixtures/perpsMarketInsights.ts
+++ b/tests/component-view/fixtures/perpsMarketInsights.ts
@@ -100,3 +100,6 @@ export function setupPerpsMarketInsightsEngineMock(
setSelectedToken: jest.fn(),
};
}
+
+/** Same engine wiring as Perps insights; use for token Asset Details + MarketInsightsView CV tests. */
+export const setupMarketInsightsEngineMock = setupPerpsMarketInsightsEngineMock;
diff --git a/tests/component-view/helpers/snapRequests.ts b/tests/component-view/helpers/snapRequests.ts
new file mode 100644
index 00000000000..6005ff7cd34
--- /dev/null
+++ b/tests/component-view/helpers/snapRequests.ts
@@ -0,0 +1,75 @@
+import Engine from '../../../app/core/Engine';
+
+type SnapRequestMethod = 'onAmountInput' | 'confirmSend';
+
+interface SetupSnapControllerHandleRequestMockOptions {
+ onAmountInputResponse?: unknown;
+ confirmSendResponse?: unknown;
+}
+
+let snapHandleRequestSpy: jest.SpyInstance | undefined;
+
+/**
+ * Intercepts SnapController handleRequest calls for component-view tests while
+ * delegating all non-snap actions to the original controller messenger.
+ */
+export function setupSnapControllerHandleRequestMock(
+ options: SetupSnapControllerHandleRequestMockOptions = {},
+) {
+ const {
+ onAmountInputResponse = { valid: true, errors: [] },
+ confirmSendResponse = { valid: true },
+ } = options;
+
+ const originalCall = Engine.controllerMessenger.call.bind(
+ Engine.controllerMessenger,
+ );
+
+ clearSnapControllerHandleRequestMock();
+
+ snapHandleRequestSpy = jest
+ .spyOn(Engine.controllerMessenger, 'call')
+ .mockImplementation((...messengerArgs) => {
+ const [action, ...args] = messengerArgs as [string, ...unknown[]];
+ if (action !== 'SnapController:handleRequest') {
+ const passthroughArgs = messengerArgs as Parameters<
+ typeof Engine.controllerMessenger.call
+ >;
+ return Reflect.apply(
+ originalCall,
+ Engine.controllerMessenger,
+ passthroughArgs,
+ ) as ReturnType;
+ }
+
+ const params = args[0] as
+ | { request?: { method?: SnapRequestMethod } }
+ | undefined;
+ const requestMethod = params?.request?.method;
+
+ if (requestMethod === 'onAmountInput') {
+ return Promise.resolve(onAmountInputResponse) as ReturnType<
+ typeof Engine.controllerMessenger.call
+ >;
+ }
+
+ if (requestMethod === 'confirmSend') {
+ return Promise.resolve(confirmSendResponse) as ReturnType<
+ typeof Engine.controllerMessenger.call
+ >;
+ }
+
+ return Promise.resolve(undefined) as ReturnType<
+ typeof Engine.controllerMessenger.call
+ >;
+ });
+
+ return snapHandleRequestSpy;
+}
+
+export function clearSnapControllerHandleRequestMock() {
+ if (snapHandleRequestSpy) {
+ snapHandleRequestSpy.mockRestore();
+ snapHandleRequestSpy = undefined;
+ }
+}
diff --git a/tests/component-view/mocks.ts b/tests/component-view/mocks.ts
index 345e9c7b445..e474a79605a 100644
--- a/tests/component-view/mocks.ts
+++ b/tests/component-view/mocks.ts
@@ -6,6 +6,7 @@
// Engine mock (singleton default export)
jest.mock('../../app/core/Engine', () => {
const engine = {
+ acceptPendingApproval: jest.fn().mockResolvedValue(undefined),
context: {
KeyringController: {
state: {
@@ -125,6 +126,56 @@ jest.mock('../../app/core/Engine', () => {
AuthenticationController: {
getBearerToken: jest.fn().mockResolvedValue('mock-bearer-token'),
},
+ // Notifications: stubbed so notification view + settings flows can call
+ // controller methods (enable / disable / toggleFeatureAnnouncements /
+ // markMetamaskNotificationsAsRead / fetchAndUpdateMetamaskNotifications
+ // / enableAccounts / disableAccounts) without touching the real services.
+ NotificationServicesController: {
+ state: {
+ isNotificationServicesEnabled: true,
+ isFeatureAnnouncementsEnabled: true,
+ metamaskNotificationsList: [],
+ metamaskNotificationsReadList: [],
+ },
+ enableMetamaskNotifications: jest.fn().mockResolvedValue(undefined),
+ disableMetamaskNotifications: jest.fn().mockResolvedValue(undefined),
+ enableNotificationServices: jest.fn().mockResolvedValue(undefined),
+ disableNotificationServices: jest.fn().mockResolvedValue(undefined),
+ enablePushNotifications: jest.fn().mockResolvedValue(undefined),
+ disablePushNotifications: jest.fn().mockResolvedValue(undefined),
+ setFeatureAnnouncementsEnabled: jest.fn().mockResolvedValue(undefined),
+ toggleFeatureAnnouncements: jest.fn().mockResolvedValue(undefined),
+ markMetamaskNotificationsAsRead: jest.fn().mockResolvedValue(undefined),
+ fetchAndUpdateMetamaskNotifications: jest
+ .fn()
+ .mockResolvedValue(undefined),
+ enableAccounts: jest.fn().mockResolvedValue(undefined),
+ disableAccounts: jest.fn().mockResolvedValue(undefined),
+ createOnChainTriggers: jest.fn().mockResolvedValue(undefined),
+ checkAccountsPresence: jest
+ .fn()
+ .mockResolvedValue({} as Record),
+ },
+ NotificationServicesPushController: {
+ state: { isPushEnabled: true, fcmToken: 'mock-fcm-token' },
+ enablePushNotifications: jest.fn().mockResolvedValue(undefined),
+ disablePushNotifications: jest.fn().mockResolvedValue(undefined),
+ updateTriggerPushNotifications: jest.fn().mockResolvedValue(undefined),
+ },
+ RemoteFeatureFlagController: {
+ state: {
+ remoteFeatureFlags: {
+ assetsNotificationsEnabled: true,
+ },
+ },
+ },
+ AiDigestController: {
+ fetchMarketInsights: jest.fn().mockResolvedValue(null),
+ fetchMarketOverview: jest.fn().mockResolvedValue(undefined),
+ },
+ RampsController: {
+ setSelectedToken: jest.fn(),
+ },
AssetsContractController: {
getTokenStandardAndDetails: jest.fn().mockResolvedValue({}),
},
diff --git a/tests/component-view/presets/marketInsightsView.ts b/tests/component-view/presets/marketInsightsView.ts
new file mode 100644
index 00000000000..1ff4dd77ea7
--- /dev/null
+++ b/tests/component-view/presets/marketInsightsView.ts
@@ -0,0 +1,29 @@
+import type { DeepPartial } from '../../../app/util/test/renderWithProvider';
+import type { RootState } from '../../../app/reducers';
+import { UnifiedRampRoutingType } from '../../../app/reducers/fiatOrders';
+import { initialStateBridge } from './bridge';
+
+/**
+ * Redux preset for MarketInsightsView component-view tests: bridge + fiat state,
+ * market insights and ramps V2 flags (version gate 0.0.0 for unit/view runs).
+ */
+export const initialStateMarketInsightsView = () =>
+ initialStateBridge({ deterministicFiat: true }).withRemoteFeatureFlags({
+ aiSocialMarketAnalysisEnabled: {
+ enabled: true,
+ minimumVersion: '0.0.0',
+ },
+ rampsUnifiedBuyV2: {
+ enabled: true,
+ minimumVersion: '0.0.0',
+ },
+ } as unknown as Record);
+
+/**
+ * Ensures `goToBuy` does not open eligibility/error modals (unified routing).
+ */
+export const fiatOrdersRampRoutingSupported: DeepPartial = {
+ fiatOrders: {
+ rampRoutingDecision: UnifiedRampRoutingType.AGGREGATOR,
+ },
+};
diff --git a/tests/component-view/presets/notifications.ts b/tests/component-view/presets/notifications.ts
new file mode 100644
index 00000000000..a81662a4f32
--- /dev/null
+++ b/tests/component-view/presets/notifications.ts
@@ -0,0 +1,156 @@
+import {
+ MOCK_FEATURE_ANNOUNCEMENT_NOTIFICATIONS,
+ MOCK_ON_CHAIN_NOTIFICATIONS,
+} from '../../../app/components/UI/Notification/__mocks__/mock_notifications';
+import type { DeepPartial } from '../../../app/util/test/renderWithProvider';
+import type { RootState } from '../../../app/reducers';
+import { AvatarAccountType } from '../../../app/component-library/components/Avatars/Avatar';
+
+/**
+ * Notifications fixture mirroring the smoke `withDefaultFixture()` mocks: the same
+ * on-chain wallet notifications + one feature announcement (`MOCK_NOTIFICATIONS`),
+ * but seeded directly into Redux instead of fetched via mockttp.
+ *
+ * Smoke E2E refs:
+ * - tests/smoke/notifications/enable-notifications-after-onboarding.spec.ts
+ * - tests/smoke/notifications/utils/mocks.ts
+ */
+export const MOCK_NOTIFICATIONS = [
+ ...MOCK_FEATURE_ANNOUNCEMENT_NOTIFICATIONS,
+ ...MOCK_ON_CHAIN_NOTIFICATIONS,
+];
+
+export const FEATURE_ANNOUNCEMENT_ID =
+ MOCK_FEATURE_ANNOUNCEMENT_NOTIFICATIONS[0].id;
+
+export const WALLET_NOTIFICATION_IDS = MOCK_ON_CHAIN_NOTIFICATIONS.map(
+ (n) => n.id,
+);
+
+/**
+ * Single HD wallet + EVM account used by the notification settings preset so
+ * `useFirstHDWalletAccounts` resolves and `AccountsList` renders one toggle for
+ * `NOTIFICATIONS_ACCOUNT_ADDRESS` (parity with smoke `DEFAULT_FIXTURE_ACCOUNT_CHECKSUM`).
+ */
+export const NOTIFICATIONS_ACCOUNT_ADDRESS =
+ '0x0000000000000000000000000000000000000001';
+const NOTIFICATIONS_ACCOUNT_ID = 'acc-notifications-1';
+const NOTIFICATIONS_HD_WALLET_ID = 'entropy:wallet1';
+const NOTIFICATIONS_HD_GROUP_ID = 'entropy:wallet1/0';
+
+interface NotificationsPresetOptions {
+ notificationsEnabled?: boolean;
+ featureAnnouncementsEnabled?: boolean;
+ pushEnabled?: boolean;
+ notifications?: typeof MOCK_NOTIFICATIONS;
+}
+
+/**
+ * Redux preset for Notification view + settings CV tests.
+ *
+ * Seeds the controllers used by `selectIs*` selectors and `getNotificationsList`
+ * so the screen renders the same data the E2E sees after the controller fetches.
+ *
+ * `AccountsController` + `AccountTreeController` are seeded with one HD wallet
+ * group containing one EVM account so `AccountsList` renders the per-account
+ * toggle in the settings flow without mocking any hooks.
+ */
+export function buildNotificationsState(
+ options: NotificationsPresetOptions = {},
+): DeepPartial {
+ const {
+ notificationsEnabled = true,
+ featureAnnouncementsEnabled = true,
+ pushEnabled = true,
+ notifications = MOCK_NOTIFICATIONS,
+ } = options;
+
+ return {
+ settings: {
+ basicFunctionalityEnabled: true,
+ avatarAccountType: AvatarAccountType.Maskicon,
+ } as DeepPartial['settings'],
+ engine: {
+ backgroundState: {
+ NotificationServicesController: {
+ isNotificationServicesEnabled: notificationsEnabled,
+ isMetamaskNotificationsFeatureSeen: true,
+ isUpdatingMetamaskNotifications: false,
+ isFetchingMetamaskNotifications: false,
+ isFeatureAnnouncementsEnabled: featureAnnouncementsEnabled,
+ isPerpsNotificationsEnabled: false,
+ isUpdatingMetamaskNotificationsAccount: [],
+ isCheckingAccountsPresence: false,
+ metamaskNotificationsReadList: [],
+ metamaskNotificationsList: notifications,
+ subscriptionAccountsSeen: [NOTIFICATIONS_ACCOUNT_ADDRESS],
+ },
+ NotificationServicesPushController: {
+ isPushEnabled: pushEnabled,
+ isUpdatingFCMToken: false,
+ fcmToken: 'mock-fcm-token',
+ },
+ RemoteFeatureFlagController: {
+ remoteFeatureFlags: {
+ assetsNotificationsEnabled: true,
+ },
+ },
+ AccountsController: {
+ internalAccounts: {
+ accounts: {
+ [NOTIFICATIONS_ACCOUNT_ID]: {
+ id: NOTIFICATIONS_ACCOUNT_ID,
+ address: NOTIFICATIONS_ACCOUNT_ADDRESS,
+ metadata: {
+ name: 'Account 1',
+ importTime: 0,
+ keyring: { type: 'HD Key Tree' },
+ },
+ options: {},
+ methods: [
+ 'personal_sign',
+ 'eth_sign',
+ 'eth_signTransaction',
+ 'eth_signTypedData_v1',
+ 'eth_signTypedData_v3',
+ 'eth_signTypedData_v4',
+ ],
+ type: 'eip155:eoa',
+ scopes: ['eip155:0'],
+ },
+ },
+ selectedAccount: NOTIFICATIONS_ACCOUNT_ID,
+ },
+ },
+ AccountTreeController: {
+ accountTree: {
+ wallets: {
+ [NOTIFICATIONS_HD_WALLET_ID]: {
+ id: NOTIFICATIONS_HD_WALLET_ID,
+ type: 'entropy',
+ metadata: {
+ name: 'Wallet 1',
+ entropy: { id: 'wallet1' },
+ },
+ groups: {
+ [NOTIFICATIONS_HD_GROUP_ID]: {
+ id: NOTIFICATIONS_HD_GROUP_ID,
+ type: 'MultipleAccount',
+ metadata: {
+ name: 'Account 1',
+ pinned: false,
+ hidden: false,
+ lastSelected: 0,
+ },
+ accounts: [NOTIFICATIONS_ACCOUNT_ID],
+ },
+ },
+ },
+ },
+ },
+ selectedAccountGroup: NOTIFICATIONS_HD_GROUP_ID,
+ },
+ },
+ } as unknown as DeepPartial['engine'],
+ };
+}
diff --git a/tests/component-view/presets/send.ts b/tests/component-view/presets/send.ts
index d1acd4b1d5b..ac89ffa98b8 100644
--- a/tests/component-view/presets/send.ts
+++ b/tests/component-view/presets/send.ts
@@ -1,5 +1,6 @@
import type { DeepPartial } from 'app/util/test/renderWithProvider';
import type { RootState } from 'app/reducers';
+import { BtcAccountType } from '@metamask/keyring-api';
/**
* Base state overrides for Send view component tests.
@@ -53,6 +54,12 @@ export interface TronSendFixture {
recipientAddresses: [string, string];
}
+export const NON_EVM_SOLANA_ACCOUNT_ID = 'solana-acc-1';
+export const NON_EVM_BTC_ACCOUNT_ID = 'btc-acc-1';
+
+const SOLANA_MAINNET_SCOPE = 'solana:5eykt4UsFv8P8NJdTREpY1vzqKqZKvdp';
+const BTC_MAINNET_SCOPE = 'bip122:000000000019d6689c085ae165831e93';
+
/**
* Builds state overrides and recipient addresses for TRON send view tests.
* Use with initialStateWallet().withOverrides(tronOverrides).build().
@@ -117,7 +124,7 @@ export function buildTronSendFixture(
...sendViewOverrides,
engine: {
backgroundState: {
- ...(baseEngine?.backgroundState ?? {}),
+ ...baseEngine?.backgroundState,
MultichainNetworkController: {
isEvmSelected: false,
},
@@ -195,3 +202,45 @@ export function buildAddressBookOverridesWithEvmContact(
},
} as unknown as DeepPartial;
}
+
+/**
+ * Builds non-EVM internal accounts (Solana + Bitcoin) for send CV tests that
+ * need a concrete `fromAccount` to drive snap amount/submit validation.
+ */
+export function buildNonEvmSendAccountsOverrides(): DeepPartial {
+ return {
+ engine: {
+ backgroundState: {
+ AccountsController: {
+ internalAccounts: {
+ accounts: {
+ [NON_EVM_SOLANA_ACCOUNT_ID]: {
+ id: NON_EVM_SOLANA_ACCOUNT_ID,
+ address: '11111111111111111111111111111111',
+ type: 'solana:data-account',
+ options: {},
+ methods: [],
+ metadata: {
+ name: 'Solana Account 1',
+ },
+ scopes: [SOLANA_MAINNET_SCOPE],
+ },
+ [NON_EVM_BTC_ACCOUNT_ID]: {
+ id: NON_EVM_BTC_ACCOUNT_ID,
+ address: 'bc1qxy2kgdygjrsqtzq2n0yrf2493p83kkfjhx0wlh',
+ type: BtcAccountType.P2wpkh,
+ options: {},
+ methods: [],
+ metadata: {
+ name: 'Bitcoin Account 1',
+ },
+ scopes: [BTC_MAINNET_SCOPE],
+ },
+ },
+ selectedAccount: NON_EVM_SOLANA_ACCOUNT_ID,
+ },
+ },
+ },
+ },
+ } as unknown as DeepPartial;
+}
diff --git a/tests/component-view/renderers/marketInsights.tsx b/tests/component-view/renderers/marketInsights.tsx
new file mode 100644
index 00000000000..1c237366a5c
--- /dev/null
+++ b/tests/component-view/renderers/marketInsights.tsx
@@ -0,0 +1,122 @@
+import '../mocks';
+import React from 'react';
+import { Text } from 'react-native';
+import { createStackNavigator } from '@react-navigation/stack';
+import renderWithProvider, {
+ type DeepPartial,
+} from '../../../app/util/test/renderWithProvider';
+import type { RootState } from '../../../app/reducers';
+import Routes from '../../../app/constants/navigation/Routes';
+import { AccessRestrictedProvider } from '../../../app/components/UI/Compliance';
+import MarketInsightsView from '../../../app/components/UI/MarketInsights/Views/MarketInsightsView/MarketInsightsView';
+import { BuildQuoteSelectors } from '../../../app/components/UI/Ramp/Aggregator/Views/BuildQuote/BuildQuote.testIds';
+import {
+ initialStateMarketInsightsView,
+ fiatOrdersRampRoutingSupported,
+} from '../presets/marketInsightsView';
+
+const RootStack = createStackNavigator();
+const BridgeInnerStack = createStackNavigator();
+const RampMainStack = createStackNavigator();
+const RampOuterStack = createStackNavigator();
+
+const BridgeViewProbe = (): React.ReactElement => (
+ BridgeView
+);
+
+const TokenSelectionProbe = (): React.ReactElement => (
+ TokenSelection
+);
+
+const AmountInputProbe = (): React.ReactElement => (
+ Continue
+);
+
+function BridgeNavigator(): React.ReactElement {
+ return (
+
+
+
+ );
+}
+
+function RampMainRoutes(): React.ReactElement {
+ return (
+
+
+
+
+ );
+}
+
+function RampNavigator(): React.ReactElement {
+ return (
+
+
+
+ );
+}
+
+interface RenderMarketInsightsViewOptions {
+ overrides?: DeepPartial;
+ initialParams?: Record;
+}
+
+/**
+ * Renders MarketInsightsView with Bridge + Ramps stacks registered so swap/buy navigation can be asserted.
+ */
+export function renderMarketInsightsViewWithNavigation(
+ options: RenderMarketInsightsViewOptions = {},
+) {
+ const { overrides, initialParams } = options;
+
+ const builder = initialStateMarketInsightsView();
+ builder.withOverrides(fiatOrdersRampRoutingSupported);
+ if (overrides) {
+ builder.withOverrides(overrides);
+ }
+ const state = builder.build();
+
+ const stackTree = (
+
+
+
+
+
+
+
+ );
+
+ return renderWithProvider(stackTree, { state });
+}
diff --git a/tests/smoke/assets/defi/view-defi-details.spec.ts b/tests/smoke/assets/defi/view-defi-details.spec.ts
deleted file mode 100644
index feb2aa83f2d..00000000000
--- a/tests/smoke/assets/defi/view-defi-details.spec.ts
+++ /dev/null
@@ -1,79 +0,0 @@
-import { SmokeNetworkAbstractions } from '../../../tags';
-import WalletView from '../../../page-objects/wallet/WalletView';
-import Assertions from '../../../framework/Assertions';
-import { withFixtures } from '../../../framework/fixtures/FixtureHelper';
-import FixtureBuilder from '../../../framework/fixtures/FixtureBuilder';
-import { loginToApp } from '../../../flows/wallet.flow';
-import { Mockttp } from 'mockttp';
-import { setupMockRequest } from '../../../api-mocking/helpers/mockHelpers';
-import { setupRemoteFeatureFlagsMock } from '../../../api-mocking/helpers/remoteFeatureFlagsHelper';
-import { remoteFeatureFlagHomepageSectionsV1Enabled } from '../../../api-mocking/mock-responses/feature-flags-mocks';
-import { defiPositionsWithData } from '../../../api-mocking/mock-responses/defi-api-mocks';
-import DefiView from '../../../page-objects/wallet/DefiView';
-import DefiPositionView from '../../../page-objects/wallet/DefiPositionView';
-
-describe(SmokeNetworkAbstractions('View DeFi details'), () => {
- it('open the Aave V3 position details', async () => {
- await withFixtures(
- {
- fixture: new FixtureBuilder().withPopularNetworks().build(),
- restartDevice: true,
- testSpecificMock: async (mockServer: Mockttp) => {
- await setupRemoteFeatureFlagsMock(mockServer, {
- ...remoteFeatureFlagHomepageSectionsV1Enabled(),
- });
-
- const { urlEndpoint, response } = defiPositionsWithData;
- await setupMockRequest(mockServer, {
- requestMethod: 'GET',
- url: urlEndpoint,
- response,
- responseCode: 200,
- });
- },
- languageAndLocale: {
- language: 'en',
- locale: 'en_US',
- },
- },
- async () => {
- await loginToApp();
- // await Assertions.expectElementToBeVisible(WalletView.defiPositionsNew);
-
- await WalletView.scrollAndTapDefiSection();
- await Assertions.expectTextDisplayed('Aave V3');
- await Assertions.expectTextDisplayed('$14.74');
- await Assertions.expectTextDisplayed('WETH +1 other');
- await Assertions.expectTextDisplayed('Uniswap V3');
- await Assertions.expectTextDisplayed('$8.48');
- await Assertions.expectTextDisplayed('WETH +1 other');
- await Assertions.expectTextDisplayed('Uniswap V2');
- await Assertions.expectTextDisplayed('$4.24');
- await Assertions.expectTextDisplayed('USDC +1 other');
- await Assertions.expectTextDisplayed('Aave V2');
- await Assertions.expectTextDisplayed('$0.33');
- await Assertions.expectTextDisplayed('USDC +1 other');
- await DefiView.checkContainerIsDisplayed();
- await Assertions.expectTextDisplayed('Aave V3');
- await Assertions.expectTextDisplayed('$14.74');
- await Assertions.expectTextDisplayed('WETH +1 other');
- await Assertions.expectTextDisplayed('Uniswap V3');
- await Assertions.expectTextDisplayed('$8.48');
- await Assertions.expectTextDisplayed('WETH +1 other');
- await Assertions.expectTextDisplayed('Uniswap V2');
- await Assertions.expectTextDisplayed('$4.24');
- await Assertions.expectTextDisplayed('USDC +1 other');
- await Assertions.expectTextDisplayed('Aave V2');
- await Assertions.expectTextDisplayed('$0.33');
- await Assertions.expectTextDisplayed('USDC +1 other');
- await DefiView.tapPosition('Aave V3');
- await DefiPositionView.checkContainersIsDisplayed();
- await Assertions.expectTextDisplayed('Supplied');
- await Assertions.expectTextDisplayed('USDT');
- await Assertions.expectTextDisplayed('WETH');
- await Assertions.expectTextDisplayed('$14.74');
- await Assertions.expectTextDisplayed('$0.30');
- },
- );
- });
-});
diff --git a/tests/smoke/assets/market-insights/view-market-insights.spec.ts b/tests/smoke/assets/market-insights/view-market-insights.spec.ts
deleted file mode 100644
index 745d6ea63ee..00000000000
--- a/tests/smoke/assets/market-insights/view-market-insights.spec.ts
+++ /dev/null
@@ -1,192 +0,0 @@
-import { SmokeNetworkAbstractions } from '../../../tags';
-import WalletView from '../../../page-objects/wallet/WalletView';
-import TokenOverview from '../../../page-objects/wallet/TokenOverview';
-import MarketInsightsEntryCard from '../../../page-objects/wallet/MarketInsightsEntryCard';
-import MarketInsightsView from '../../../page-objects/wallet/MarketInsightsView';
-import QuoteView from '../../../page-objects/swaps/QuoteView';
-import Assertions from '../../../framework/Assertions';
-import { withFixtures } from '../../../framework/fixtures/FixtureHelper';
-import FixtureBuilder from '../../../framework/fixtures/FixtureBuilder';
-import { loginToApp } from '../../../flows/wallet.flow';
-import { Mockttp } from 'mockttp';
-import { setupMockRequest } from '../../../api-mocking/helpers/mockHelpers';
-import { setupRemoteFeatureFlagsMock } from '../../../api-mocking/helpers/remoteFeatureFlagsHelper';
-import { remoteFeatureFlagMarketInsightsEnabled } from '../../../api-mocking/mock-responses/feature-flags-mocks';
-import {
- marketInsightsWithData,
- marketInsightsNoData,
-} from '../../../api-mocking/mock-responses/market-insights-api-mocks';
-
-const TOKEN = 'Ethereum';
-
-const mockWithData = async (mockServer: Mockttp) => {
- await setupRemoteFeatureFlagsMock(mockServer, {
- ...remoteFeatureFlagMarketInsightsEnabled(),
- });
- const { urlEndpoint, response, responseCode } = marketInsightsWithData;
- await setupMockRequest(mockServer, {
- requestMethod: 'GET',
- url: urlEndpoint,
- response,
- responseCode,
- });
-};
-
-const navigateToMarketInsightsView = async () => {
- await loginToApp();
- await WalletView.waitForTokenToBeReady(TOKEN);
- await WalletView.tapOnToken(TOKEN);
- await Assertions.expectElementToBeVisible(TokenOverview.container, {
- description: 'Asset details screen is visible after tapping Ethereum',
- });
- await MarketInsightsEntryCard.expectEntryCardVisible();
- await MarketInsightsEntryCard.tapEntryCard();
- await MarketInsightsView.expectViewVisible();
-};
-
-describe(
- SmokeNetworkAbstractions('View Market Insights on Asset Details'),
- () => {
- it('displays market insights content and navigates to swap', async () => {
- await withFixtures(
- {
- fixture: new FixtureBuilder().build(),
- restartDevice: true,
- testSpecificMock: mockWithData,
- languageAndLocale: { language: 'en', locale: 'en_US' },
- },
- async () => {
- await navigateToMarketInsightsView();
-
- await Assertions.expectTextDisplayed(
- 'Ethereum shows strong momentum amid institutional demand',
- { description: 'Market Insights headline is displayed' },
- );
-
- await Assertions.expectTextDisplayed(
- 'Ethereum continues to attract institutional interest with increasing on-chain activity and a healthy DeFi ecosystem.',
- { description: 'Market Insights summary is displayed' },
- );
-
- await Assertions.expectTextDisplayed('Institutional Adoption', {
- description: 'Market Insights first trend title is displayed',
- });
-
- await Assertions.expectTextDisplayed('DeFi Activity Surge', {
- description: 'Market Insights second trend title is displayed',
- });
-
- await MarketInsightsView.expectSwapButtonVisible();
-
- await MarketInsightsView.tapSwapButton();
- await QuoteView.isVisible();
- },
- );
- });
-
- it('does not display entry card when API returns no data', async () => {
- await withFixtures(
- {
- fixture: new FixtureBuilder().build(),
- restartDevice: true,
- testSpecificMock: async (mockServer: Mockttp) => {
- await setupRemoteFeatureFlagsMock(mockServer, {
- ...remoteFeatureFlagMarketInsightsEnabled(),
- });
- const { urlEndpoint, response, responseCode } =
- marketInsightsNoData;
- await setupMockRequest(mockServer, {
- requestMethod: 'GET',
- url: urlEndpoint,
- response,
- responseCode,
- });
- },
- languageAndLocale: { language: 'en', locale: 'en_US' },
- },
- async () => {
- await loginToApp();
- await WalletView.waitForTokenToBeReady(TOKEN);
- await WalletView.tapOnToken(TOKEN);
- await Assertions.expectElementToBeVisible(TokenOverview.container, {
- description:
- 'Asset details screen is visible after tapping Ethereum',
- });
- await MarketInsightsEntryCard.expectEntryCardNotVisible();
- },
- );
- });
-
- it('does not display entry card when feature flag is disabled', async () => {
- await withFixtures(
- {
- fixture: new FixtureBuilder().build(),
- restartDevice: true,
- testSpecificMock: async (mockServer: Mockttp) => {
- await setupRemoteFeatureFlagsMock(mockServer, {
- ...remoteFeatureFlagMarketInsightsEnabled(false),
- });
- const { urlEndpoint, response, responseCode } =
- marketInsightsWithData;
- await setupMockRequest(mockServer, {
- requestMethod: 'GET',
- url: urlEndpoint,
- response,
- responseCode,
- });
- },
- languageAndLocale: { language: 'en', locale: 'en_US' },
- },
- async () => {
- await loginToApp();
- await WalletView.waitForTokenToBeReady(TOKEN);
- await WalletView.tapOnToken(TOKEN);
- await Assertions.expectElementToBeVisible(TokenOverview.container, {
- description:
- 'Asset details screen is visible after tapping Ethereum',
- });
- await MarketInsightsEntryCard.expectEntryCardNotVisible();
- },
- );
- });
-
- it('shows sources bottom sheet when tapping a trend item', async () => {
- await withFixtures(
- {
- fixture: new FixtureBuilder().build(),
- restartDevice: true,
- testSpecificMock: mockWithData,
- languageAndLocale: { language: 'en', locale: 'en_US' },
- },
- async () => {
- await navigateToMarketInsightsView();
- await MarketInsightsView.tapTrendItem(0);
- await Assertions.expectTextDisplayed(
- 'Spot Ethereum ETFs See Record Weekly Inflows',
- { description: 'Trend sources bottom sheet shows article title' },
- );
- },
- );
- });
-
- it('can tap thumbs up feedback button', async () => {
- await withFixtures(
- {
- fixture: new FixtureBuilder().build(),
- restartDevice: true,
- testSpecificMock: mockWithData,
- languageAndLocale: { language: 'en', locale: 'en_US' },
- },
- async () => {
- await navigateToMarketInsightsView();
- await MarketInsightsView.scrollToThumbsUp();
- await MarketInsightsView.tapThumbsUpButton();
- // Note: Toast verification skipped due to Detox/Reanimated compatibility issues.
- // The toast appears correctly (verified manually) but Detox cannot detect it.
- // This test verifies the thumbs up button is tappable.
- await MarketInsightsView.expectThumbsUpButtonVisible();
- },
- );
- });
- },
-);
diff --git a/tests/smoke/confirmations/send/send-btc-token.spec.ts b/tests/smoke/confirmations/send/send-btc-token.spec.ts
index 388ce6182a3..0a27181f113 100644
--- a/tests/smoke/confirmations/send/send-btc-token.spec.ts
+++ b/tests/smoke/confirmations/send/send-btc-token.spec.ts
@@ -1,40 +1,10 @@
-import TokenOverview from '../../../page-objects/wallet/TokenOverview';
-import WalletView from '../../../page-objects/wallet/WalletView';
+/* eslint-disable jest/no-disabled-tests -- E2E skipped; covered by component view tests */
import { SmokeConfirmations } from '../../../tags';
-import { withFixtures } from '../../../framework/fixtures/FixtureHelper';
-import FixtureBuilder from '../../../framework/fixtures/FixtureBuilder';
-import { loginToApp } from '../../../flows/wallet.flow';
-import Assertions from '../../../framework/Assertions';
-
-const TOKEN = 'Bitcoin';
describe(SmokeConfirmations('Send Bitcoin'), () => {
- it('hides send button for zero balance token', async () => {
- await withFixtures(
- {
- fixture: new FixtureBuilder().build(),
- restartDevice: true,
- },
- async () => {
- await loginToApp();
- // Making the assertion before disabling synchronization so that flags
- // are properly fetched and this mocked flag is set
- await Assertions.expectElementToNotBeVisible(
- WalletView.balanceEmptyStateContainer,
- {
- description: 'Balance empty state container should not be visible',
- timeout: 30000,
- },
- );
- await device.disableSynchronization();
- await WalletView.tapOnToken(TOKEN, 0);
- // Token details V2 layout hides Send and shows Receive for zero-balance tokens
- await Assertions.expectElementToBeVisible(TokenOverview.receiveButton, {
- description:
- 'Receive button should be visible for zero-balance token',
- timeout: 15000,
- });
- },
- );
+ // Moved to cv tests (send.non-evm.view.test.tsx)
+ it.skip('Send BTC', async () => {
+ // TODO: Update the test so if does a full e2e (define what should do). Keep this test to have something tested on e2e.
+ // https://consensyssoftware.atlassian.net/browse/MMQA-1794
});
});
diff --git a/tests/smoke/confirmations/send/send-erc20-token.spec.ts b/tests/smoke/confirmations/send/send-erc20-token.spec.ts
index 55bad325fd6..9a8197f9e66 100644
--- a/tests/smoke/confirmations/send/send-erc20-token.spec.ts
+++ b/tests/smoke/confirmations/send/send-erc20-token.spec.ts
@@ -1,3 +1,4 @@
+/* eslint-disable jest/no-disabled-tests -- E2E skipped; covered by component view tests */
import FixtureBuilder, {
DEFAULT_FIXTURE_ACCOUNT_CHECKSUM,
} from '../../../framework/fixtures/FixtureBuilder';
@@ -272,134 +273,6 @@ async function setupAccountsApiMocks(mockServer: Mockttp): Promise {
}
describe(SmokeConfirmations('Send ERC20 asset'), () => {
- it('should send USDC amount 5 to an address', async () => {
- await withFixtures(
- {
- dapps: [
- {
- dappVariant: DappVariants.TEST_DAPP,
- },
- ],
- fixture: ({ localNodes }) => {
- const node = localNodes?.[0] as unknown as AnvilManager;
- const rpcPort =
- node instanceof AnvilManager
- ? (node.getPort() ?? AnvilPort())
- : undefined;
-
- return new FixtureBuilder()
- .withNetworkController({
- chainId: '0x539',
- rpcUrl: `http://localhost:${rpcPort ?? AnvilPort()}`,
- type: 'custom',
- nickname: 'Local RPC',
- ticker: 'ETH',
- })
- .withTokensForAllPopularNetworks([
- {
- address: '0x0000000000000000000000000000000000000000',
- symbol: 'ETH',
- decimals: 18,
- name: 'Ethereum',
- },
- {
- address: '0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48',
- symbol: 'USDC',
- decimals: 6,
- name: 'USD Coin',
- },
- {
- address: '0x6B175474E89094C44Da98b954EedeAC495271d0F',
- symbol: 'DAI',
- decimals: 18,
- name: 'Dai Stablecoin',
- },
- ])
- .build();
- },
- testSpecificMock: setupAccountsApiMocks,
- restartDevice: true,
- },
- async () => {
- await loginToApp();
- await device.disableSynchronization();
-
- // send 5 USDC
- await WalletView.tapWalletSendButton();
- await SendView.selectERC20Token();
- await SendView.pressAmountFiveButton();
- await SendView.pressContinueButton();
- await SendView.inputRecipientAddress(RECIPIENT);
- await SendView.pressReviewButton();
- await FooterActions.tapCancelButton();
- },
- );
- });
-
- it('should send USDC amount 50% to an address', async () => {
- await withFixtures(
- {
- dapps: [
- {
- dappVariant: DappVariants.TEST_DAPP,
- },
- ],
- fixture: ({ localNodes }) => {
- const node = localNodes?.[0] as unknown as { getPort?: () => number };
- const rpcPort =
- node instanceof AnvilManager
- ? (node.getPort() ?? AnvilPort())
- : undefined;
-
- return new FixtureBuilder()
- .withNetworkController({
- chainId: '0x539',
- rpcUrl: `http://localhost:${rpcPort ?? AnvilPort()}`,
- type: 'custom',
- nickname: 'Local RPC',
- ticker: 'ETH',
- })
- .withTokensForAllPopularNetworks([
- {
- address: '0x0000000000000000000000000000000000000000',
- symbol: 'ETH',
- decimals: 18,
- name: 'Ethereum',
- },
- {
- address: '0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48',
- symbol: 'USDC',
- decimals: 6,
- name: 'USD Coin',
- },
- {
- address: '0x6B175474E89094C44Da98b954EedeAC495271d0F',
- symbol: 'DAI',
- decimals: 18,
- name: 'Dai Stablecoin',
- },
- ])
- .build();
- },
- testSpecificMock: setupAccountsApiMocks,
- restartDevice: true,
- },
- async () => {
- await loginToApp();
- await device.disableSynchronization();
-
- // send 50% USDC
- await WalletView.tapWalletSendButton();
- await SendView.selectERC20Token();
- await SendView.pressFiftyPercentButton();
- await SendView.pressContinueButton();
- await SendView.inputRecipientAddress(RECIPIENT);
- await SendView.pressReviewButton();
- await FooterActions.tapCancelButton();
- },
- );
- });
-
it('should send USDC send maxto an address', async () => {
await withFixtures(
{
diff --git a/tests/smoke/confirmations/send/send-native-token.spec.ts b/tests/smoke/confirmations/send/send-native-token.spec.ts
index dd13e0c3646..40c4ee407d7 100644
--- a/tests/smoke/confirmations/send/send-native-token.spec.ts
+++ b/tests/smoke/confirmations/send/send-native-token.spec.ts
@@ -1,3 +1,4 @@
+/* eslint-disable jest/no-disabled-tests -- E2E skipped; covered by component view tests */
import FixtureBuilder, {
DEFAULT_FIXTURE_ACCOUNT,
} from '../../../framework/fixtures/FixtureBuilder';
@@ -19,7 +20,8 @@ import { validateTransactionHashInTransactionFinalizedEvent } from './metricsVal
const RECIPIENT = '0x0c54fccd2e384b4bb6f2e405bf5cbc15a017aafb';
describe(SmokeConfirmations('Send native asset'), () => {
- it('should send ETH to an address', async () => {
+ // Moved partially to cv tests (send.view.test.tsx, EVM coverage)
+ it('should send MAX balance ETH to an address', async () => {
await withFixtures(
{
dapps: [
@@ -83,10 +85,10 @@ describe(SmokeConfirmations('Send native asset'), () => {
}) => {
await loginToApp();
await device.disableSynchronization();
- // send 5 ETH
+ // send Max ETH
await WalletView.tapWalletSendButton();
await SendView.selectEthereumToken();
- await SendView.pressAmountFiveButton();
+ await SendView.pressAmountMaxButton();
await SendView.pressContinueButton();
await SendView.inputRecipientAddress(RECIPIENT);
await SendView.pressReviewButton();
@@ -100,30 +102,6 @@ describe(SmokeConfirmations('Send native asset'), () => {
localNodes,
mockServer,
);
-
- // send 50% ETH
- await TabBarComponent.tapWallet();
- await WalletView.tapWalletSendButton();
- await SendView.selectEthereumToken();
- await SendView.pressFiftyPercentButton();
- await SendView.pressContinueButton();
- await SendView.inputRecipientAddress(RECIPIENT);
- await SendView.pressReviewButton();
- await FooterActions.tapConfirmButton();
- await TabBarComponent.tapActivity();
- await Assertions.expectTextDisplayed('Confirmed');
-
- // send Max ETH
- await TabBarComponent.tapWallet();
- await WalletView.tapWalletSendButton();
- await SendView.selectEthereumToken();
- await SendView.pressAmountMaxButton();
- await SendView.pressContinueButton();
- await SendView.inputRecipientAddress(RECIPIENT);
- await SendView.pressReviewButton();
- await FooterActions.tapConfirmButton();
- await TabBarComponent.tapActivity();
- await Assertions.expectTextDisplayed('Confirmed');
},
);
});
diff --git a/tests/smoke/confirmations/send/send-solana-token.spec.ts b/tests/smoke/confirmations/send/send-solana-token.spec.ts
index 752dd88d742..a82c031d744 100644
--- a/tests/smoke/confirmations/send/send-solana-token.spec.ts
+++ b/tests/smoke/confirmations/send/send-solana-token.spec.ts
@@ -1,63 +1,12 @@
-import TokenOverview from '../../../page-objects/wallet/TokenOverview';
-import WalletView from '../../../page-objects/wallet/WalletView';
-import { SmokeConfirmations } from '../../../tags';
-import { loginToApp } from '../../../flows/wallet.flow';
-import { withFixtures } from '../../../framework/fixtures/FixtureHelper';
-import FixtureBuilder from '../../../framework/fixtures/FixtureBuilder';
-import { Assertions } from '../../../framework';
-import { Mockttp } from 'mockttp';
-import { setupMockRequest } from '../../../api-mocking/helpers/mockHelpers';
+/* eslint-disable jest/no-disabled-tests -- E2E skipped; covered by component view tests */
-// const RECIPIENT = '4Nd1mZyJY5ZqzR3n8bQF7h5L2Q9gY1yTtM6nQhc7P1Dp';
+import { SmokeConfirmations } from '../../../tags';
describe(SmokeConfirmations('Send SOL token'), () => {
- it('should send solana to an address', async () => {
- await withFixtures(
- {
- fixture: new FixtureBuilder().build(),
- restartDevice: true,
- testSpecificMock: async (mockServer: Mockttp) => {
- await setupMockRequest(mockServer, {
- requestMethod: 'GET',
- url: 'https://digest.api.cx.metamask.io/api/v1/asset-summary?asset=solana:5eykt4UsFv8P8NJdTREpY1vzqKqZKvdp/slip44:501',
- response: {
- id: 'mock-id',
- assetId: 'Solana',
- assetSymbol: 'SOL',
- digest: {
- version: '1.0',
- asset: 'SOL',
- generatedAt: '2026-04-08T07:01:42.371Z',
- headline: 'SOL trades near $85 amid ecosystem developments',
- summary:
- 'Solana SOL token is trading near $85 with a $48 billion market cap.',
- trends: [],
- social: [],
- sources: [],
- },
- generatedAt: '2026-04-08T07:01:42.392Z',
- processingTime: 1000,
- success: true,
- error: null,
- createdAt: '2026-04-08T07:01:42.401Z',
- updatedAt: '2026-04-08T07:01:42.401Z',
- },
- responseCode: 200,
- });
- },
- },
- async () => {
- await loginToApp();
- await device.disableSynchronization();
- await WalletView.tapOnToken('Solana');
- await TokenOverview.tapSendButton();
+ // Moved to cv tests (send.non-evm.view.test.tsx)
- // Since we're not yet mockign Solana and there's residual balance that
- // can be flaky when loading we're only checking that we're on the
- // correct screen and sending the correct token.
- await Assertions.expectTextDisplayed('Send');
- await Assertions.expectTextDisplayed('SOL');
- },
- );
+ it.skip('should send solana to an address', async () => {
+ // TODO: Update the test so if does a full e2e (define what should do). Keep this test to have something tested on e2e.
+ // https://consensyssoftware.atlassian.net/browse/MMQA-1792
});
});
diff --git a/tests/smoke/confirmations/send/send-tron-token.spec.ts b/tests/smoke/confirmations/send/send-tron-token.spec.ts
index 1666af66124..e1dc4a23482 100644
--- a/tests/smoke/confirmations/send/send-tron-token.spec.ts
+++ b/tests/smoke/confirmations/send/send-tron-token.spec.ts
@@ -1,26 +1,11 @@
-import SendView from '../../../page-objects/Send/RedesignedSendView';
-import TokenOverview from '../../../page-objects/wallet/TokenOverview';
-import WalletView from '../../../page-objects/wallet/WalletView';
+/* eslint-disable jest/no-disabled-tests -- E2E skipped; covered by component view tests */
import { SmokeConfirmations } from '../../../tags';
-import { withFixtures } from '../../../framework/fixtures/FixtureHelper';
-import FixtureBuilder from '../../../framework/fixtures/FixtureBuilder';
-import { loginToApp } from '../../../flows/wallet.flow';
describe(SmokeConfirmations('Send TRX token'), () => {
+ // Moved to cv tests (send.non-evm.view.test.tsx)
+
it.skip('shows insufficient funds', async () => {
- await withFixtures(
- {
- fixture: new FixtureBuilder().build(),
- restartDevice: true,
- },
- async () => {
- await loginToApp();
- await device.disableSynchronization();
- await WalletView.tapOnToken('Tron');
- await TokenOverview.tapSendButton();
- await SendView.enterZeroAmount();
- await SendView.checkInsufficientFundsError();
- },
- );
+ // TODO: Update the test so if does a full e2e (define what should do). Keep this test to have something tested on e2e.
+ // https://consensyssoftware.atlassian.net/browse/MMQA-1793
});
});
diff --git a/tests/smoke/confirmations/signatures/alert-system.spec.ts b/tests/smoke/confirmations/signatures/alert-system.spec.ts
deleted file mode 100644
index de78b5db952..00000000000
--- a/tests/smoke/confirmations/signatures/alert-system.spec.ts
+++ /dev/null
@@ -1,265 +0,0 @@
-import Assertions from '../../../framework/Assertions';
-import Browser from '../../../page-objects/Browser/BrowserView';
-import FixtureBuilder from '../../../framework/fixtures/FixtureBuilder';
-import RequestTypes from '../../../page-objects/Browser/Confirmations/RequestTypes';
-import AlertSystem from '../../../page-objects/Browser/Confirmations/AlertSystem';
-import TestDApp from '../../../page-objects/Browser/TestDApp';
-import { loginToApp } from '../../../flows/wallet.flow';
-import { navigateToBrowserView } from '../../../flows/browser.flow';
-import { SmokeConfirmations } from '../../../tags';
-import { withFixtures } from '../../../framework/fixtures/FixtureHelper';
-import FooterActions from '../../../page-objects/Browser/Confirmations/FooterActions';
-import {
- buildPermissions,
- getTestDappLocalUrl,
-} from '../../../framework/fixtures/FixtureUtils';
-import { DappVariants } from '../../../framework/Constants';
-import { Mockttp } from 'mockttp';
-import {
- setupMockRequest,
- setupMockPostRequest,
-} from '../../../api-mocking/helpers/mockHelpers';
-import {
- SECURITY_ALERTS_BENIGN_RESPONSE,
- securityAlertsUrl,
-} from '../../../api-mocking/mock-responses/security-alerts-mock';
-import { setupRemoteFeatureFlagsMock } from '../../../api-mocking/helpers/remoteFeatureFlagsHelper';
-import { confirmationFeatureFlags } from '../../../api-mocking/mock-responses/feature-flags-mocks';
-
-const typedSignRequestBody = {
- method: 'eth_signTypedData',
- params: [
- [
- { type: 'string', name: 'Message', value: 'Hi, Alice!' },
- { type: 'uint32', name: 'A number', value: '1337' },
- ],
- '0x76cf1CdD1fcC252442b50D6e97207228aA4aefC3',
- ],
-};
-
-describe(SmokeConfirmations('Alert System - Signature'), () => {
- const runTest = async (
- testSpecificMock: (mockServer: Mockttp) => Promise,
- alertAssertion: () => Promise,
- ) => {
- await withFixtures(
- {
- dapps: [
- {
- dappVariant: DappVariants.TEST_DAPP,
- },
- ],
- fixture: new FixtureBuilder()
- .withSepoliaNetwork()
- .withPermissionControllerConnectedToTestDapp(
- buildPermissions(['0xaa36a7']),
- )
- .build(),
- restartDevice: true,
- testSpecificMock,
- },
- async () => {
- await loginToApp();
- await navigateToBrowserView();
- await Browser.navigateToTestDApp();
- await TestDApp.tapTypedSignButton();
- await Assertions.expectElementToBeVisible(
- RequestTypes.TypedSignRequest,
- );
- await alertAssertion();
- },
- );
- };
-
- describe('Security Alert API', () => {
- it('should sign typed message', async () => {
- const testSpecificMock = async (mockServer: Mockttp) => {
- await setupRemoteFeatureFlagsMock(
- mockServer,
- Object.assign({}, ...confirmationFeatureFlags),
- );
-
- await setupMockPostRequest(
- mockServer,
- securityAlertsUrl('0xaa36a7'),
- { ...typedSignRequestBody, origin: getTestDappLocalUrl() },
- SECURITY_ALERTS_BENIGN_RESPONSE,
- {
- statusCode: 201,
- ignoreFields: [
- 'id',
- 'jsonrpc',
- 'toNative',
- 'networkClientId',
- 'traceContext',
- ],
- },
- );
- };
-
- await runTest(testSpecificMock, async () => {
- await Assertions.expectElementToNotBeVisible(
- AlertSystem.securityAlertBanner,
- );
- });
- });
-
- it('should show security alert for malicious request, acknowledge and confirm the signature', async () => {
- const testSpecificMock = async (mockServer: Mockttp) => {
- await setupRemoteFeatureFlagsMock(
- mockServer,
- Object.assign({}, ...confirmationFeatureFlags),
- );
-
- await setupMockPostRequest(
- mockServer,
- 'https://security-alerts.api.cx.metamask.io/validate/0xaa36a7',
- { ...typedSignRequestBody, origin: getTestDappLocalUrl() },
- {
- block: 20733277,
- result_type: 'Malicious',
- reason: 'malicious_domain',
- description: `You're interacting with a malicious domain. If you approve this request, you might lose your assets.`,
- features: [],
- },
- {
- ignoreFields: [
- 'id',
- 'jsonrpc',
- 'toNative',
- 'networkClientId',
- 'traceContext',
- ],
- },
- );
- };
-
- await runTest(testSpecificMock, async () => {
- await Assertions.expectElementToBeVisible(
- AlertSystem.securityAlertBanner,
- );
- await Assertions.expectElementToBeVisible(
- AlertSystem.securityAlertResponseMaliciousBanner,
- );
- // Confirm request
- await FooterActions.tapConfirmButton();
- await Assertions.expectElementToBeVisible(
- AlertSystem.confirmAlertModal,
- );
- // Acknowledge and confirm alert
- await AlertSystem.tapConfirmAlertCheckbox();
- await AlertSystem.tapConfirmAlertButton();
- await Assertions.expectElementToNotBeVisible(
- RequestTypes.TypedSignRequest,
- );
- });
- });
-
- it('should show security alert for error when validating request fails', async () => {
- const testSpecificMock = async (mockServer: Mockttp) => {
- await setupRemoteFeatureFlagsMock(
- mockServer,
- Object.assign({}, ...confirmationFeatureFlags),
- );
-
- await setupMockRequest(mockServer, {
- requestMethod: 'GET',
- url: 'https://static.cx.metamask.io/api/v1/confirmations/ppom/ppom_version.json',
- response: {
- message: 'Internal Server Error',
- },
- responseCode: 500,
- });
-
- await setupMockPostRequest(
- mockServer,
- 'https://security-alerts.api.cx.metamask.io/validate/0xaa36a7',
- { ...typedSignRequestBody, origin: getTestDappLocalUrl() },
- {
- error: 'Internal Server Error',
- message: 'An unexpected error occurred on the server.',
- },
- {
- statusCode: 500,
- ignoreFields: [
- 'id',
- 'jsonrpc',
- 'toNative',
- 'networkClientId',
- 'traceContext',
- ],
- },
- );
- };
-
- await runTest(testSpecificMock, async () => {
- await Assertions.expectElementToBeVisible(
- AlertSystem.securityAlertBanner,
- );
- await Assertions.expectElementToBeVisible(
- AlertSystem.securityAlertResponseFailedBanner,
- );
- });
- });
- });
-
- describe('Inline Alert', () => {
- it('should show mismatch field alert, click the alert, acknowledge and confirm the signature', async () => {
- const testSpecificMock = async (mockServer: Mockttp) => {
- await setupRemoteFeatureFlagsMock(
- mockServer,
- Object.assign({}, ...confirmationFeatureFlags),
- );
- };
-
- await withFixtures(
- {
- dapps: [
- {
- dappVariant: DappVariants.TEST_DAPP,
- },
- ],
- fixture: new FixtureBuilder()
- .withSepoliaNetwork()
- .withPermissionControllerConnectedToTestDapp(
- buildPermissions(['0xaa36a7']),
- )
- .withPreferencesController({
- securityAlertsEnabled: true,
- })
- .build(),
- restartDevice: true,
- testSpecificMock,
- },
- async () => {
- await loginToApp();
- await navigateToBrowserView();
- await Browser.navigateToTestDApp();
- await TestDApp.tapSIWEBadDomainButton();
- await Assertions.expectElementToBeVisible(
- RequestTypes.PersonalSignRequest,
- );
- await Assertions.expectElementToBeVisible(AlertSystem.inlineAlert);
- // Open alert modal and acknowledge the alert
- await AlertSystem.tapInlineAlert();
- await Assertions.expectElementToBeVisible(
- AlertSystem.alertMismatchTitle,
- );
- await AlertSystem.tapAcknowledgeAlertModal();
- await AlertSystem.tapAcknowledgeAlertModalButton();
- // Confirm request
- await FooterActions.tapConfirmButton();
- await Assertions.expectElementToBeVisible(
- AlertSystem.confirmAlertModal,
- );
- // Acknowledge and confirm alert
- await AlertSystem.tapConfirmAlertCheckbox();
- await AlertSystem.tapConfirmAlertButton();
- await Assertions.expectElementToNotBeVisible(
- RequestTypes.PersonalSignRequest,
- );
- },
- );
- });
- });
-});
diff --git a/tests/smoke/confirmations/transactions/gas-fee-tokens-eip-7702-sponsored.spec.ts b/tests/smoke/confirmations/transactions/gas-fee-tokens-eip-7702-sponsored.spec.ts
index 8b421943a85..0f7a0bb92fd 100644
--- a/tests/smoke/confirmations/transactions/gas-fee-tokens-eip-7702-sponsored.spec.ts
+++ b/tests/smoke/confirmations/transactions/gas-fee-tokens-eip-7702-sponsored.spec.ts
@@ -1,3 +1,4 @@
+/* eslint-disable jest/no-disabled-tests -- E2E skipped; covered by component view tests */
import FixtureBuilder, {
DEFAULT_FIXTURE_ACCOUNT,
} from '../../../framework/fixtures/FixtureBuilder';
@@ -262,6 +263,7 @@ describe(
jest.setTimeout(2500000);
});
+ // Also implemented in cv tests (eip-7702-sponsored-relay-api-failure.view.test.tsx)
it('fails transaction if error occurs on API', async () => {
await withFixtures(
{
diff --git a/tests/smoke/notifications/enable-notifications-after-onboarding.spec.ts b/tests/smoke/notifications/enable-notifications-after-onboarding.spec.ts
index c22adc3a597..3768833f73b 100644
--- a/tests/smoke/notifications/enable-notifications-after-onboarding.spec.ts
+++ b/tests/smoke/notifications/enable-notifications-after-onboarding.spec.ts
@@ -17,6 +17,7 @@ describe(SmokeNetworkAbstractions('Notification Onboarding'), () => {
jest.setTimeout(170000);
});
+ // TODO: Update the test so if does a full e2e (define what should do). Keep this test to have something tested on e2e.
it('should enable notifications and view feature announcements and wallet notifications', async () => {
// Notification mocks are now enabled by default inside the fixture helper
// since they're turned on by default
diff --git a/tests/smoke/notifications/notification-settings-flow.spec.ts b/tests/smoke/notifications/notification-settings-flow.spec.ts
index 9b26bb9af16..b4dba61cb06 100644
--- a/tests/smoke/notifications/notification-settings-flow.spec.ts
+++ b/tests/smoke/notifications/notification-settings-flow.spec.ts
@@ -1,3 +1,4 @@
+/* eslint-disable jest/no-disabled-tests -- E2E skipped; covered by component view tests */
import { SmokeNetworkAbstractions } from '../../tags';
import Assertions from '../../framework/Assertions';
import { withFixtures } from '../../framework/fixtures/FixtureHelper';
@@ -15,7 +16,7 @@ describe(SmokeNetworkAbstractions('Notification Onboarding'), () => {
jest.setTimeout(170000);
});
- it('should enable notifications and toggle feature announcements and account notifications', async () => {
+ it.skip('should enable notifications and toggle feature announcements and account notifications', async () => {
await withFixtures(
{
fixture: new FixtureBuilder().withDefaultFixture().build(),
From 0a4a7b44baf9168696fc172e6893e27451347fd6 Mon Sep 17 00:00:00 2001
From: =?UTF-8?q?Patryk=20=C5=81ucka?=
<5708018+PatrykLucka@users.noreply.github.com>
Date: Thu, 7 May 2026 10:08:29 +0200
Subject: [PATCH 05/13] fix(locales): correct placeholder syntax in Turkish
translations for liquidation and bonus messages (#29779)
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
## **Description**
Turkish copy used `%{{variable}}` so a literal percent could sit before
the interpolated value. **i18n-js** (used by `react-native-i18n`) treats
`%` followed by `{` as the start of a `%{name}` placeholder, so
`%{{...}}` was parsed as one invalid token and users saw messages like
`[missing %{{apy}} value]` instead of the real value.
All affected `tr.json` strings were updated to **`%%{variable}`**: a
literal `%` plus the supported `%{variable}` interpolation, preserving
“percent before number” wording while matching the same interpolation
keys the app already passes (`apy`, `percentage`, `fee`, `distance`,
etc.). No application code changes.
## **Changelog**
CHANGELOG entry: Fixed Turkish strings that showed broken i18n
placeholders when a percent sign appeared before an interpolated value.
## **Related issues**
Fixes:
## **Manual testing steps**
```gherkin
Feature: Turkish locale i18n placeholders
Scenario: APY and bonus copy interpolate with a leading percent
Given the app language is set to Türkçe (tr)
And the user opens a surface that shows Money APY label, MetaMask Card APY bullet, earn/mUSD bonus copy, bridge fee disclaimer, swap slippage errors, or perps liquidation distance strings
When those screens render
Then no string should contain a substring like "[missing" or "value]"
And percent-prefixed values should show as "%" immediately followed by the numeric or formatted value (e.g. "%4" for APY), not a raw placeholder token
```
## **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 translation-only change that adjusts placeholder formatting;
main risk is minor copy/interpolation regressions if any key names are
mismatched.
>
> **Overview**
> Fixes broken Turkish (`tr.json`) interpolations where a leading
percent sign caused i18n placeholders like `%{{percentage}}` to be
parsed incorrectly.
>
> Updates affected strings (perps liquidation/fees, swap/bridge fee and
slippage messages, earn/mUSD bonus and APY/cashback labels) to use
`%%{...}` so the UI shows a literal `%` followed by the interpolated
value.
>
> Reviewed by [Cursor Bugbot](https://cursor.com/bugbot) for commit
4a6b57e12150b4f77b998e81431831e9503c1de8. Bugbot is set up for automated
code reviews on this repo. Configure
[here](https://www.cursor.com/dashboard/bugbot).
---
locales/languages/tr.json | 58 +++++++++++++++++++--------------------
1 file changed, 29 insertions(+), 29 deletions(-)
diff --git a/locales/languages/tr.json b/locales/languages/tr.json
index 5a67f03f591..ed0315f8a6e 100644
--- a/locales/languages/tr.json
+++ b/locales/languages/tr.json
@@ -1134,7 +1134,7 @@
"edit_button": "Düzenle"
},
"stop_loss_prompt": {
- "near_liquidation_title": "Likidasyona yalnızca %{{distance}} uzaktasınız",
+ "near_liquidation_title": "Likidasyona yalnızca %%{distance} uzaktasınız",
"near_liquidation_subtitle": "Pozisyonunuzun riskini azaltmak için marj ekleyin",
"add_margin_button": "Ekle",
"protect_losses_title": "Daha fazla zarara karşı korunun",
@@ -1386,7 +1386,7 @@
"current_price": "Mevcut fiyat",
"liquidation_price": "Likidasyon fiyatı",
"liquidation_distance": "Likidasyon mesafesi",
- "liquidation_warning": "Fiyat %{{percentage}} {{direction}} yaşarsa pozisyonunuz likidasyona uğrar",
+ "liquidation_warning": "Fiyat %%{percentage} {{direction}} yaşarsa pozisyonunuz likidasyona uğrar",
"drops": "düşüş",
"rises": "yükseliş",
"set_leverage": "{{leverage}} kaldıraç ayarla"
@@ -1816,7 +1816,7 @@
"provider_fee": "Sağlayıcı ücreti",
"bridge_fee": "Köprü ücreti",
"total": "Toplam ücretler",
- "discount_message": "MetaMask Ödülleri ile %{{percentage}} tasarruf ediyorsunuz."
+ "discount_message": "MetaMask Ödülleri ile %%{percentage} tasarruf ediyorsunuz."
},
"closing_fees": {
"title": "Kapatma ücretleri",
@@ -2515,7 +2515,7 @@
"slippage": "Kayma",
"price_details": "Fiyat bilgileri",
"prediction_order": "Tahmin emri",
- "prediction_order_description": "Her biri {{price}} fiyatta ~{{count}} sözleşme. Son tutar, emir defteri kullanılabilirliğe göre değişiklik gösterebilir (%{{slippage}} orana kadar).",
+ "prediction_order_description": "Her biri {{price}} fiyatta ~{{count}} sözleşme. Son tutar, emir defteri kullanılabilirliğe göre değişiklik gösterebilir (%%{slippage} orana kadar).",
"metamask_fee_description": "Bu tahmin işlemine yönelik hizmet ücreti",
"exchange_fee": "Borsa ücreti",
"exchange_fee_description": "Borsaya veya piyasaya ödenen ücret",
@@ -5735,8 +5735,8 @@
"included": "dahil",
"max_gas_fee": "Maks. gaz ücreti",
"edit": "Düzenle",
- "quotes_include_fee": "%{{fee}} MetaMask ücreti tekliflere dahildir",
- "quotes_include_gas_and_metamask_fee": "Gaz ve %{{fee}} MetaMask ücreti teklife dahildir",
+ "quotes_include_fee": "%%{fee} MetaMask ücreti tekliflere dahildir",
+ "quotes_include_gas_and_metamask_fee": "Gaz ve %%{fee} MetaMask ücreti teklife dahildir",
"tap_to_swap": "Takas işlemi gerçekleştirmek için dokunun",
"swipe_to_swap": "Swap gerçekleştirmek için kaydır",
"swipe_to": "Swap gerçekleştirmek için",
@@ -6340,16 +6340,16 @@
},
"earn": {
"claimable_bonus_tooltip": "mUSD tuttuğunuz için kazandığınız yıllıklandırılmış bonus. Bonusunuzu Linea üzerinde günlük olarak alabilirsiniz.",
- "earn_a_percentage_bonus": "%{{percentage}} bonus kazanın",
- "percentage_bonus": "%{{percentage}} bonus",
+ "earn_a_percentage_bonus": "%%{percentage} bonus kazanın",
+ "percentage_bonus": "%%{percentage} bonus",
"claimable_bonus": "Alınabilir bonus",
"claim_bonus": "Bonusu al",
"claim_bonus_with_fiat": "{{amount}} al",
"claim_bonus_subtitle": "Bonus, {{networkName}} üzerinde ödenecektir.",
- "percentage_bonus_on_linea": "Linea üzerinde %{{percentage}} bonus",
+ "percentage_bonus_on_linea": "Linea üzerinde %%{percentage} bonus",
"claim": "Al",
"sounds_good": "Kulağa hoş geliyor",
- "claimable_bonus_tooltip_with_percentage": "mUSD tuttuğunuz için kazandığınız %{{percentage}} yıllıklandırılmış bonus. Bonusunuzu Linea üzerinde günlük olarak alabilirsiniz.",
+ "claimable_bonus_tooltip_with_percentage": "mUSD tuttuğunuz için kazandığınız %%{percentage} yıllıklandırılmış bonus. Bonusunuzu Linea üzerinde günlük olarak alabilirsiniz.",
"your_bonus": "Bonusunuz",
"estimated_annual_bonus": "Tahmini yıllık bonus",
"lifetime_bonus_claimed": "Toplam bonus alındı",
@@ -6357,7 +6357,7 @@
"no_accruing_bonus": "Biriken bonus yok",
"claim_amount_bonus": "{{amount}}$ bonus al",
"your_bonus_tooltip_your_bonus": "Bonusunuz",
- "your_bonus_tooltip_your_bonus_desc": ": Yalnızca mUSD tutarak kazandığınız %{{percentage}} yıllıklandırılmış bonus. Bonusunuzu Linea üzerinde günlük olarak alabilirsiniz. ",
+ "your_bonus_tooltip_your_bonus_desc": ": Yalnızca mUSD tutarak kazandığınız %%{percentage} yıllıklandırılmış bonus. Bonusunuzu Linea üzerinde günlük olarak alabilirsiniz. ",
"your_bonus_tooltip_annual_bonus": "Tahmini yıllık bonus: ",
"your_bonus_tooltip_annual_bonus_desc": "Mevcut bakiyenize ve oranınıza göre bir yıl içinde kazanabileceğiniz tahmini tutar. Oran değişkendir ve farklılık gösterebilir.",
"your_bonus_tooltip_lifetime_bonus": "Alınan toplam bonus: ",
@@ -6448,14 +6448,14 @@
"musd_conversion": {
"ok": "Tamam",
"continue": "Devam et",
- "convert_and_get_percentage_bonus": "Dönüştür ve %{{percentage}} al",
+ "convert_and_get_percentage_bonus": "Dönüştür ve %%{percentage} al",
"your_musd": "mUSD bakiyeniz",
"balance_breakdown_title": "Ağa göre mUSD bakiyeleriniz",
"balance_amount": "{{amount}} mUSD",
"balance_amount_with_symbol": "{{amount}} {{symbol}}",
"balance_fiat_unavailable": "—",
"convert_to_musd": "mUSD'ye dönüştür",
- "get_a_percentage_musd_bonus": "%{{percentage}} mUSD bonus al",
+ "get_a_percentage_musd_bonus": "%%{percentage} mUSD bonus al",
"convert": "Dönüştür",
"fetching_quote": "Teklif alınıyor...",
"you_convert": "Dönüştürdüğünüz tutar",
@@ -6471,16 +6471,16 @@
"failed": "mUSD dönüştürme işlemi başarısız oldu"
},
"education": {
- "heading": "STABİL KRİPTO PARALARDA\n%{{percentage}} AL",
- "description": "Stabil kripto paralarınızı mUSD'ye dönüştürün ve günlük olarak alabileceğiniz %{{percentage}} oranına varan yıllıklandırılmış bonus kazanın.",
+ "heading": "STABİL KRİPTO PARALARDA\n%%{percentage} AL",
+ "description": "Stabil kripto paralarınızı mUSD'ye dönüştürün ve günlük olarak alabileceğiniz %%{percentage} oranına varan yıllıklandırılmış bonus kazanın.",
"terms_apply": "Şartlar uygulanır.",
"primary_button": "Başlarken",
"secondary_button": "Şimdi değil"
},
"buy_musd": "mUSD al",
"get_musd": "mUSD kazan",
- "bonus_title": "Stabil kripto paranızda %{{percentage}} bonus kazanın",
- "bonus_description": "Stabil kripto paranızı mUSD'ye dönüştürün ve %{{percentage}} oranında yıllıklandırılmış bonus alın.",
+ "bonus_title": "Stabil kripto paranızda %%{percentage} bonus kazanın",
+ "bonus_description": "Stabil kripto paranızı mUSD'ye dönüştürün ve %%{percentage} oranında yıllıklandırılmış bonus alın.",
"powered_by_relay": "Destekleyen Relay",
"max": "Maksimum",
"quick_convert_button": "Dönüştür",
@@ -6488,15 +6488,15 @@
"tooltip_title": "mUSD ile getiri elde et",
"tooltip_content": "USDC, USDT veya DAI'nizi MetaMask'in dolar destekli stabil kripto parası olan mUSD'ye dönüştürün. Tuttuğunuz her dolar için {{apy}} getiri elde edin.",
"quick_convert": {
- "title": "Dönüştür ve %{{percentage}} al",
- "subtitle": "Stabil kripto paralarınızı mUSD'ye dönüştürün ve günlük olarak alabileceğiniz %{{percentage}} oranına varan yıllıklandırılmış bonus alın.",
+ "title": "Dönüştür ve %%{percentage} al",
+ "subtitle": "Stabil kripto paralarınızı mUSD'ye dönüştürün ve günlük olarak alabileceğiniz %%{percentage} oranına varan yıllıklandırılmış bonus alın.",
"inline_failed_message": "Dönüştürme işlemi başarısız oldu. Tekrar deneyin.",
"confirmation": {
"title": "Maksimumu dönüştür"
}
},
- "percentage_bonus": "%{{percentage}} bonus",
- "claim_percentage_bonus": "%{{percentage}} bonus al",
+ "percentage_bonus": "%%{percentage} bonus",
+ "claim_percentage_bonus": "%%{percentage} bonus al",
"rate": "Oran"
},
"bonus_claim": {
@@ -6519,7 +6519,7 @@
},
"money": {
"title": "Para",
- "apy_label": "%{{percentage}} Yıllık Bileşik Getiri",
+ "apy_label": "%%{percentage} Yıllık Bileşik Getiri",
"apy_info_label": "Yıllık Yüzde Getiri (APY) bilgisi",
"onboarding": {
"step_progress": "Adım {{current}}/{{total}}",
@@ -6565,12 +6565,12 @@
"subtitle": "Paranızı dilediğiniz yerde harcayın.",
"virtual_card": "Sanal kart",
"metal_card": "Metal kart",
- "cashback": "%{{percentage}} para iadesi",
+ "cashback": "%%{percentage} para iadesi",
"get_now": "Hemen alın",
"link_title": "MetaMask Card'ı bağla",
"link_subtitle": "Para bakiyenizi harcayın ve kazanın.",
"link_bullet_cashback": "%3'e kadar para iadesi",
- "link_bullet_apy": "%{{apy}} APY'ye varan",
+ "link_bullet_apy": "%%{apy} APY'ye varan",
"link_card": "Kartı bağla"
},
"what_you_get": {
@@ -6834,7 +6834,7 @@
"you_could_earn_up_to": "Yılda",
"per_year_on_your_tokens": "kazanabilirsiniz",
"deposit": "Para Yatır",
- "gas_cost_impact_warning": "Uyarı: İşlemin gaz maliyeti yatırdığınız paranın %{{percentOverDeposit}} fazlası olacaktır.",
+ "gas_cost_impact_warning": "Uyarı: İşlemin gaz maliyeti yatırdığınız paranın %%{percentOverDeposit} fazlası olacaktır.",
"earnings_history_title": "{{ticker}} kazançları",
"apr": "APR",
"interactive_chart": {
@@ -7202,7 +7202,7 @@
"title": "Köprü",
"submitting_transaction": "Gönderiliyor",
"fetching_quote": "Teklif alınıyor",
- "fee_disclaimer": "%{{feePercentage}} MetaMask ücreti dahildir.",
+ "fee_disclaimer": "%%{feePercentage} MetaMask ücreti dahildir.",
"no_mm_fee": "MM ücreti yok",
"token_suspicious": "Şüpheli",
"token_malicious": "Kötü amaçlı",
@@ -7248,8 +7248,8 @@
"confirm": "Onayla",
"exceeding_upper_slippage_warning": "Yüksek kayma; bu durum istenmeyen bir takas ile sonuçlanabilir",
"exceeding_lower_slippage_warning": "Düşük kayma; bu durum istenmeyen bir takas ile sonuçlanabilir",
- "exceeding_lower_slippage_error": "%{{value}} değerin üzerinde olan bir değer girin",
- "exceeding_upper_slippage_error": "%{{value}} değerin üzerinde olan bir değer giremezsiniz",
+ "exceeding_lower_slippage_error": "%%{value} değerin üzerinde olan bir değer girin",
+ "exceeding_upper_slippage_error": "%%{value} değerin üzerinde olan bir değer giremezsiniz",
"custom": "Özel",
"invalid_recipient_address": "Geçersiz adres",
"select_quote": "Teklif Seç",
@@ -8995,7 +8995,7 @@
},
"cash_filled_state": {
"add": "Ekle",
- "apy": "%{{percentage}} Yıllık Bileşik Getiri"
+ "apy": "%%{percentage} Yıllık Bileşik Getiri"
},
"tokens": "tokenlar",
"perpetuals": "Sürekli Vadeli İşlemler",
From 2c7c3e8e313090fd7b9883098a631cd5adef0b5a Mon Sep 17 00:00:00 2001
From: Ale Som <560018+alucardzom@users.noreply.github.com>
Date: Thu, 7 May 2026 10:21:19 +0200
Subject: [PATCH 06/13] fix(ci): increase apt retry timeout to prevent kill
EPERM crash (#29715)
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
## **Description**
### Problem
Android E2E runs are crashing with `Error: kill EPERM` in the "Set up
E2E environment" step
([example](https://github.com/MetaMask/metamask-mobile/actions/runs/25337405885/job/74288509502?pr=29670)).
This was introduced by #29236, which wrapped `sudo apt-get` inside
`nick-fields/retry` with `timeout_minutes: 3`.
**Root cause:** When the 3-minute timeout fires, `nick-fields/retry`
calls `process.kill()` on the child process. But `sudo apt-get` runs as
**root** while the Cirrus runner process runs as **admin** — Node.js
gets `EPERM` (permission denied) on the kill syscall. This is a [known
upstream bug](https://github.com/nick-fields/retry/issues/124) (open
since Oct 2023, 11 upvotes, unpatched).
**Why the timeout fires:** `DPkg::Lock::Timeout=120` means `apt-get` can
legitimately wait up to 120s for the dpkg lock on each of the two `sudo`
calls (`update` + `install`). With slow Ubuntu mirrors on top, total
time can approach or exceed 180s (3 min), triggering the timeout. The
3-minute value was tightened from the original 5-minute design in a
follow-up commit on #29236, which didn't account for the double
lock-wait scenario.
### Fix
1. **Restore `timeout_minutes` from 3 to 5** — gives 300s per attempt.
Even worst-case (120s lock on update + 120s lock on install + 30s actual
install = 270s) fits with 30s headroom. `apt-get` resolves on its own
(success or dpkg lock timeout error) before the retry timeout fires, so
the `process.kill()` path — and the EPERM bug — is never hit.
2. **Add `retry_on: error`** — only retry when `apt-get` exits with a
non-zero code (mirror desync, lock timeout), not when
`nick-fields/retry`'s own timeout fires. A timeout-triggered retry would
crash with EPERM anyway, so this avoids a wasted attempt.
### Timing analysis
| Scenario | Duration | Fits in 5 min? |
|----------|----------|----------------|
| Happy path (no lock, fast mirror) | 5-15s | Yes (295s margin) |
| Lock on one call + normal mirror | 120s + 15s = 135s | Yes (165s
margin) |
| Lock on both calls + slow mirror | 240s + 30s = 270s | Yes (30s
margin) |
| Lock on both + very slow mirror | 240s + 60s = 300s | Boundary — but
this is extremely unlikely |
## **Changelog**
CHANGELOG entry: null
## **Related issues**
Refs: INFRA-3580
Fixes regression from #29236
## **Manual testing steps**
N/A — CI infrastructure fix. Validated by any Android E2E workflow run.
The timeout increase is transparent in the happy path (apt-get takes
5-15s).
## **Screenshots/Recordings**
### **Before**
N/A
### **After**
N/A
## **Pre-merge author checklist**
- [x] I've followed [MetaMask Contributor
Docs](https://github.com/MetaMask/contributor-docs) and [MetaMask Mobile
Coding
Standards](https://github.com/MetaMask/metamask-mobile/blob/main/.github/guidelines/CODING_GUIDELINES.md).
- [x] I've completed the PR template to the best of my ability
- [x] I've included tests if applicable
- [x] I've documented my code using [JSDoc](https://jsdoc.app/) format
if applicable
- [x] I've applied the right labels on the PR (see [labeling
guidelines](https://github.com/MetaMask/metamask-mobile/blob/main/.github/guidelines/LABELING_GUIDELINES.md)).
Not required for external contributors.
## **Pre-merge reviewer checklist**
- [ ] I've manually tested the PR (e.g. pull and build branch, run the
app, test code being changed).
- [ ] I confirm that this PR addresses all acceptance criteria described
in the ticket it closes and includes the necessary testing evidence such
as recordings and or screenshots.
Made with [Cursor](https://cursor.com)
---
> [!NOTE]
> **Low Risk**
> Low risk CI-only change that adjusts retry behavior for Linux
`apt-get` during Android E2E setup; primary impact is longer waits
before failing and fewer timeout-triggered retries.
>
> **Overview**
> Reduces flaky Android E2E setup failures by updating the
`setup-e2e-env` composite action to **increase** the `nick-fields/retry`
`apt-get` wrapper timeout from 3 to 5 minutes.
>
> The retry wrapper is also configured with `retry_on: error` so retries
only happen on non-zero exits, avoiding retries triggered by the
action's own timeout.
>
> Reviewed by [Cursor Bugbot](https://cursor.com/bugbot) for commit
8bcc99579ea970d15d6bbd0c5e729e94a55c7859. Bugbot is set up for automated
code reviews on this repo. Configure
[here](https://www.cursor.com/dashboard/bugbot).
Co-authored-by: Cursor
---
.github/actions/setup-e2e-env/action.yml | 3 ++-
1 file changed, 2 insertions(+), 1 deletion(-)
diff --git a/.github/actions/setup-e2e-env/action.yml b/.github/actions/setup-e2e-env/action.yml
index 07669962f93..e84b7cdda72 100644
--- a/.github/actions/setup-e2e-env/action.yml
+++ b/.github/actions/setup-e2e-env/action.yml
@@ -116,9 +116,10 @@ runs:
if: ${{ inputs.platform == 'android' && inputs.setup-simulator == 'true' && runner.os == 'Linux' }}
uses: nick-fields/retry@ce71cc2ab81d554ebbe88c79ab5975992d79ba08 #v3.0.2
with:
- timeout_minutes: 3
+ timeout_minutes: 5
max_attempts: 3
retry_wait_seconds: 30
+ retry_on: error
on_retry_command: sudo apt-get clean
command: |
set -euo pipefail
From b22424a5e061d631b249b3b1079a413c0acc15c3 Mon Sep 17 00:00:00 2001
From: Prithpal Sooriya
Date: Thu, 7 May 2026 09:38:26 +0100
Subject: [PATCH 07/13] fix: add token image fallback in AssetLogo
(ASSETS-3156) (#29827)
## **Description**
This PR fixes token logo rendering for non-native assets when
`asset.image` is empty (the issue affecting mUSD/Money integration).
The `AssetLogo` component now:
- computes a fallback icon URL via `getAssetImageUrl` when the primary
image is missing,
- only attempts fallback generation for valid CAIP or strict-hex chain
IDs,
- passes `imageSource={{ uri: imageUri }}` so undefined URI values are
handled directly by `AvatarToken`.
It also adds unit tests to cover fallback behavior and unsupported
chain-id behavior.
## **Changelog**
CHANGELOG entry: fix: fixed token list items to use a fallback icon when
token image URLs are missing
## **Related issues**
Fixes: https://consensyssoftware.atlassian.net/browse/ASSETS-3156
## **Manual testing steps**
```gherkin
Feature: Token list item image fallback
Scenario: Token with missing image URL still shows an icon
Given a token list item where asset.image is empty
When the token list item renders
Then AssetLogo resolves and uses a fallback image URL for valid chain IDs
And the token icon is shown instead of an empty image
Scenario: Unsupported chain id does not crash icon rendering
Given a token list item where asset.image is empty and chainId is unsupported
When the token list item renders
Then AssetLogo does not attempt invalid fallback URL generation
And AvatarToken renders safely when uri is undefined
```
## **Screenshots/Recordings**
### **Before**
N/A
### **After**
N/A
## **Pre-merge author checklist**
- [x] I've followed [MetaMask Contributor
Docs](https://github.com/MetaMask/contributor-docs) and [MetaMask Mobile
Coding
Standards](https://github.com/MetaMask/metamask-mobile/blob/main/.github/guidelines/CODING_GUIDELINES.md).
- [x] I've completed the PR template to the best of my ability
- [x] I've included tests if applicable
- [x] I've documented my code using [JSDoc](https://jsdoc.app/) format
if applicable
- [x] I've applied the right labels on the PR (see [labeling
guidelines](https://github.com/MetaMask/metamask-mobile/blob/main/.github/guidelines/LABELING_GUIDELINES.md)).
Not required for external contributors.
#### Performance checks (if applicable)
- [x] I've tested on Android
- Ideally on a mid-range device; emulator is acceptable
- [x] I've tested with a power user scenario
- Use these [power-user
SRPs](https://consensyssoftware.atlassian.net/wiki/spaces/TL1/pages/edit-v2/401401446401?draftShareId=9d77e1e1-4bdc-4be1-9ebb-ccd916988d93)
to import wallets with many accounts and tokens
- [x] I've instrumented key operations with Sentry traces for production
performance metrics
- See [`trace()`](/app/util/trace.ts) for usage and
[`addToken`](/app/components/Views/AddAsset/components/AddCustomToken/AddCustomToken.tsx#L274)
for an example
For performance guidelines and tooling, see the [Performance
Guide](https://consensyssoftware.atlassian.net/wiki/spaces/TL1/pages/400085549067/Performance+Guide+for+Engineers).
## **Pre-merge reviewer checklist**
- [ ] I've manually tested the PR (e.g. pull and build branch, run the
app, test code being changed).
- [ ] I confirm that this PR addresses all acceptance criteria described
in the ticket it closes and includes the necessary testing evidence such
as recordings and or screenshots.
---------
Co-authored-by: Cursor Agent
---
.../components/AssetLogo/AssetLogo.test.tsx | 84 +++++++++++++++++++
.../Assets/components/AssetLogo/AssetLogo.tsx | 18 +++-
2 files changed, 101 insertions(+), 1 deletion(-)
diff --git a/app/components/UI/Assets/components/AssetLogo/AssetLogo.test.tsx b/app/components/UI/Assets/components/AssetLogo/AssetLogo.test.tsx
index d0005df6b91..fd0b6752538 100644
--- a/app/components/UI/Assets/components/AssetLogo/AssetLogo.test.tsx
+++ b/app/components/UI/Assets/components/AssetLogo/AssetLogo.test.tsx
@@ -4,8 +4,15 @@ import AssetLogo from './AssetLogo';
import AvatarToken from '../../../../../component-library/components/Avatars/Avatar/variants/AvatarToken';
import { AvatarSize } from '../../../../../component-library/components/Avatars/Avatar';
import NetworkAssetLogo from '../../../NetworkAssetLogo';
+import { getAssetImageUrl } from '../../../Bridge/hooks/useAssetMetadata/utils';
+
+jest.mock('../../../Bridge/hooks/useAssetMetadata/utils', () => ({
+ getAssetImageUrl: jest.fn(),
+}));
describe('AssetLogo', () => {
+ const mockedGetAssetImageUrl = jest.mocked(getAssetImageUrl);
+
const mockState = {
engine: {
backgroundState: {
@@ -19,6 +26,10 @@ describe('AssetLogo', () => {
},
};
+ beforeEach(() => {
+ mockedGetAssetImageUrl.mockReset();
+ });
+
it('renders asset logo for non-native assets', () => {
const asset = {
decimals: 18,
@@ -87,4 +98,77 @@ describe('AssetLogo', () => {
}),
);
});
+
+ it('uses fallback image URL when image is an empty string', () => {
+ const fallbackImageUrl = 'https://example.com/fallback.png';
+ mockedGetAssetImageUrl.mockReturnValue(fallbackImageUrl);
+
+ const asset = {
+ decimals: 18,
+ address: '0x456',
+ chainId: '0x1',
+ symbol: 'TEST',
+ name: 'Test Token',
+ balance: '1.23',
+ balanceFiat: '$123.00',
+ isNative: false,
+ isETH: false,
+ image: '',
+ logo: 'https://example.com/logo.png',
+ aggregators: [],
+ };
+
+ const { UNSAFE_getByType } = renderWithProvider(
+ ,
+ {
+ state: mockState,
+ },
+ );
+
+ expect(mockedGetAssetImageUrl).toHaveBeenCalledWith('0x456', '0x1');
+
+ const assetAvatar = UNSAFE_getByType(AvatarToken);
+ expect(assetAvatar.props).toStrictEqual({
+ name: 'TEST',
+ imageSource: {
+ uri: fallbackImageUrl,
+ },
+ size: AvatarSize.Lg,
+ });
+ });
+
+ it('does not call fallback image utility for unsupported chainId', () => {
+ const asset = {
+ decimals: 18,
+ address: '0x456',
+ chainId: '1',
+ symbol: 'TEST',
+ name: 'Test Token',
+ balance: '1.23',
+ balanceFiat: '$123.00',
+ isNative: false,
+ isETH: false,
+ image: '',
+ logo: 'https://example.com/logo.png',
+ aggregators: [],
+ };
+
+ const { UNSAFE_getByType } = renderWithProvider(
+ ,
+ {
+ state: mockState,
+ },
+ );
+
+ expect(mockedGetAssetImageUrl).not.toHaveBeenCalled();
+
+ const assetAvatar = UNSAFE_getByType(AvatarToken);
+ expect(assetAvatar.props).toStrictEqual({
+ name: 'TEST',
+ imageSource: {
+ uri: undefined,
+ },
+ size: AvatarSize.Lg,
+ });
+ });
});
diff --git a/app/components/UI/Assets/components/AssetLogo/AssetLogo.tsx b/app/components/UI/Assets/components/AssetLogo/AssetLogo.tsx
index 09a82712f0f..20f1078c604 100644
--- a/app/components/UI/Assets/components/AssetLogo/AssetLogo.tsx
+++ b/app/components/UI/Assets/components/AssetLogo/AssetLogo.tsx
@@ -1,11 +1,25 @@
import React from 'react';
+import { isCaipChainId, isStrictHexString } from '@metamask/utils';
import { AvatarSize } from '../../../../../component-library/components/Avatars/Avatar';
import NetworkAssetLogo from '../../../NetworkAssetLogo';
import AvatarToken from '../../../../../component-library/components/Avatars/Avatar/variants/AvatarToken';
import { TokenI } from '../../../Tokens/types';
import { useStyles } from '../../../../../component-library/hooks/useStyles';
+import { getAssetImageUrl } from '../../../Bridge/hooks/useAssetMetadata/utils';
import styleSheet from './AssetLogo.styles';
+const getFallbackAssetImageUrl = (asset: TokenI): string | undefined => {
+ if (!asset.chainId) {
+ return undefined;
+ }
+
+ if (!isCaipChainId(asset.chainId) && !isStrictHexString(asset.chainId)) {
+ return undefined;
+ }
+
+ return getAssetImageUrl(asset.address, asset.chainId);
+};
+
const AssetLogo = ({ asset }: { asset: TokenI }) => {
const { styles } = useStyles(styleSheet, {});
@@ -22,10 +36,12 @@ const AssetLogo = ({ asset }: { asset: TokenI }) => {
);
}
+ const imageUri = asset.image || getFallbackAssetImageUrl(asset);
+
return (
);
From 7a4f52676b913555ce6f2d0c9b74dc03ef677183 Mon Sep 17 00:00:00 2001
From: Harry Le <43307514+LeVinhGithub@users.noreply.github.com>
Date: Thu, 7 May 2026 16:50:47 +0700
Subject: [PATCH 08/13] test: MMQA - 1711 - [Mobile] Update Predict e2e tests
to enable features that were disabled due to feature flag updates (#29154)
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
## **Description**
Brings Predict E2E test feature flag mocks into parity with production
values.
Two remote feature flag overrides in predict smoke tests diverged from
production after a feature flag registry sync ([PR
#28444](https://github.com/MetaMask/metamask-mobile/pull/28444)). This
PR aligns the mocks:
- **`exploreSectionsOrder`**: Replaced the explicit section ordering
arrays with `{}` in 4 predict smoke specs. On production this flag is
empty; the app falls back to hardcoded defaults which produce the same
layout. The Homepage component does not consume this flag at all — it
only affects TrendingView/Explore, which these tests don't exercise.
- **`predictLiveSports`**: Added `'nba'` to the leagues list (now
`['nfl', 'nba']`) to match production. This causes NBA markets (Spurs
vs. Pelicans) to render via `PredictGameDetailsContent`, which uses a
different cash-out button test ID
(`predict-picks-cash-out-button-{positionId}`). Updated the
`PredictDetailsPage` page object with a new
`tapGameCashOutButton(positionId)` method and updated
`predict-cash-out.spec.ts` to use it.
## **Changelog**
CHANGELOG entry: null
## **Related issues**
Fixes:
[MMQA-1711](https://consensyssoftware.atlassian.net/browse/MMQA-1711)
## **Manual testing steps**
**Feature: Predict E2E prod parity**
**Scenario: Cash out on NBA game market uses correct button**
- Given the predict smoke tests use production-parity feature flag mocks
- When the predict-cash-out test runs against Spurs vs. Pelicans
- Then the test taps the game-details cash-out button (PredictPickItem
testID)
- And the cash-out flow completes successfully
**Scenario: Predict tests work without explicit exploreSectionsOrder**
- Given exploreSectionsOrder is set to `{}` in all predict smoke specs
- When any predict smoke test runs (cash-out, open-position,
geo-restriction, withdraw)
- Then the Homepage renders with the default section order
- And the test navigates to predictions by test ID without issue
## **Screenshots/Recordings**
### **Before**
N/A — test-only changes, no UI impact.
### **After**
N/A — test-only changes, no UI impact.
## **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.
## **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-1711]:
https://consensyssoftware.atlassian.net/browse/MMQA-1711?atlOrigin=eyJpIjoiNWRkNTljNzYxNjVmNDY3MDlhMDU5Y2ZhYzA5YTRkZjUiLCJwIjoiZ2l0aHViLWNvbS1KU1cifQ
---
> [!NOTE]
> **Low Risk**
> Test-only changes that adjust feature-flag mocks and E2E selectors;
low product risk, but could affect Predict smoke test stability if
testIDs/position IDs drift.
>
> **Overview**
> Aligns Predict E2E remote feature-flag mocks with current production
defaults by removing the `predictLiveSports` override from
`remoteFeatureFlagPredictEnabled`.
>
> Updates Predict smoke specs to interact with the *game-details* UI:
adds a shared `SPURS_PELICANS_POSITION_ID`, introduces
`PredictDetailsPage.tapGameCashOutButton(positionId)` and
`tapGameBetYesButton()`, and switches cash-out/position-opening tests to
use the new testIDs for NBA/game markets.
>
> Reviewed by [Cursor Bugbot](https://cursor.com/bugbot) for commit
f4527366cc247cca44a3dde90d73f773316d9ef5. Bugbot is set up for automated
code reviews on this repo. Configure
[here](https://www.cursor.com/dashboard/bugbot).
---
.../mock-responses/feature-flags-mocks.ts | 7 ----
.../polymarket/polymarket-constants.ts | 4 +++
.../Predict/PredictDetailsPage.ts | 35 +++++++++++++++++++
tests/smoke/predict/predict-cash-out.spec.ts | 5 ++-
.../predict/predict-geo-restriction.spec.ts | 7 +++-
.../predict/predict-open-position.spec.ts | 14 +-------
6 files changed, 50 insertions(+), 22 deletions(-)
diff --git a/tests/api-mocking/mock-responses/feature-flags-mocks.ts b/tests/api-mocking/mock-responses/feature-flags-mocks.ts
index 6169d1937ba..7367345a830 100644
--- a/tests/api-mocking/mock-responses/feature-flags-mocks.ts
+++ b/tests/api-mocking/mock-responses/feature-flags-mocks.ts
@@ -132,13 +132,6 @@ export const remoteFeatureFlagPredictEnabled = (enabled = true) => ({
enabled: false,
minimumVersion: '7.60.0',
},
- // Exclude 'nba' — NBA markets render PredictGameDetailsContent which uses a different cash-out testID not yet supported by E2E page objects
- predictLiveSports: {
- versions: {
- '7.67.0': { enabled: true, leagues: ['nfl'] },
- '7.70.0': { enabled: true, leagues: ['nfl'] },
- },
- },
});
export const remoteFeatureFlagHomepageSectionsV1Enabled = (enabled = true) => ({
diff --git a/tests/api-mocking/mock-responses/polymarket/polymarket-constants.ts b/tests/api-mocking/mock-responses/polymarket/polymarket-constants.ts
index 3ff6ba185ee..86f6034d29c 100644
--- a/tests/api-mocking/mock-responses/polymarket/polymarket-constants.ts
+++ b/tests/api-mocking/mock-responses/polymarket/polymarket-constants.ts
@@ -39,6 +39,10 @@ export const CONDITIONAL_TOKENS_CONTRACT_ADDRESS =
export const POLYGON_EIP7702_CONTRACT_ADDRESS =
'0x63c0c19a282a1B52b07dD5a65b58948A07DAE32B';
+// Predict position ID for Spurs vs. Pelicans
+export const SPURS_PELICANS_POSITION_ID =
+ '110743925263777693447488608878982152642205002490046349037358337248548507433643';
+
// EIP-7702 format: 0xef01 (magic byte) + 00 (padding) + 20-byte contract address
// This format indicates an EOA is upgraded with EIP-7702
export const EIP7702_CODE_FORMAT = (contractAddress: string): string => {
diff --git a/tests/page-objects/Predict/PredictDetailsPage.ts b/tests/page-objects/Predict/PredictDetailsPage.ts
index 77019731e33..a7a69f24796 100644
--- a/tests/page-objects/Predict/PredictDetailsPage.ts
+++ b/tests/page-objects/Predict/PredictDetailsPage.ts
@@ -14,6 +14,13 @@ import {
PredictMarketDetailsSelectorsIDs,
PredictMarketDetailsSelectorsText,
} from '../../../app/components/UI/Predict/Predict.testIds';
+import { PREDICT_PICK_ITEM_TEST_IDS } from '../../../app/components/UI/Predict/components/PredictPicks/PredictPickItem.testIds';
+import {
+ PREDICT_GAME_DETAILS_FOOTER,
+ PREDICT_GAME_DETAILS_FOOTER_TEST_IDS,
+} from '../../../app/components/UI/Predict/components/PredictGameDetailsFooter/PredictGameDetailsFooter.testIds';
+import { PREDICT_ACTION_BUTTONS_TEST_IDS } from '../../../app/components/UI/Predict/components/PredictActionButtons/PredictActionButtons.testIds';
+import { PREDICT_BET_BUTTONS_TEST_IDS } from '../../../app/components/UI/Predict/components/PredictActionButtons/PredictBetButtons.testIds';
class PredictDetailsPage {
get container(): EncapsulatedElementType {
@@ -208,6 +215,14 @@ class PredictDetailsPage {
});
}
+ get gameBetYesButton(): EncapsulatedElementType {
+ const testID = `${PREDICT_GAME_DETAILS_FOOTER}${PREDICT_GAME_DETAILS_FOOTER_TEST_IDS.ACTION_BUTTONS}${PREDICT_ACTION_BUTTONS_TEST_IDS.PREDICT_BET_BUTTON}${PREDICT_BET_BUTTONS_TEST_IDS.PREDICT_BET_BUTTON_YES}`;
+ return encapsulated({
+ detox: () => Matchers.getElementByID(testID),
+ appium: () => PlaywrightMatchers.getElementById(testID, { exact: true }),
+ });
+ }
+
async waitForScreenToDisplay(): Promise {
await Assertions.expectElementToBeVisible(this.container, {
description: 'Predict market details screen',
@@ -246,12 +261,32 @@ class PredictDetailsPage {
});
}
+ getGameCashOutButton(positionId: string): EncapsulatedElementType {
+ const testID = `${PREDICT_PICK_ITEM_TEST_IDS.PREDICT_PICKS_CASH_OUT_BUTTON}-${positionId}`;
+ return encapsulated({
+ detox: () => Matchers.getElementByID(testID),
+ appium: () => PlaywrightMatchers.getElementById(testID, { exact: true }),
+ });
+ }
+
+ async tapGameCashOutButton(positionId: string): Promise {
+ await UnifiedGestures.waitAndTap(this.getGameCashOutButton(positionId), {
+ description: 'Game details cash out button',
+ });
+ }
+
async tapOpenPositionValue(): Promise {
await UnifiedGestures.waitAndTap(this.getOpenPositionValueButton(), {
description: 'Celtics outcome button',
});
}
+ async tapGameBetYesButton(): Promise {
+ await UnifiedGestures.waitAndTap(this.gameBetYesButton, {
+ description: 'Game bet yes button',
+ });
+ }
+
async tapPositionAmount(amount: string): Promise {
const digits = amount.split('');
diff --git a/tests/smoke/predict/predict-cash-out.spec.ts b/tests/smoke/predict/predict-cash-out.spec.ts
index eccb5145054..f6bf9afb02b 100644
--- a/tests/smoke/predict/predict-cash-out.spec.ts
+++ b/tests/smoke/predict/predict-cash-out.spec.ts
@@ -24,6 +24,7 @@ import TabBarComponent from '../../page-objects/wallet/TabBarComponent';
import ActivitiesView from '../../page-objects/Transactions/ActivitiesView';
import PredictActivityDetails from '../../page-objects/Transactions/predictionsActivityDetails';
import { predictCashOutFlowAnalyticsExpectations } from '../../helpers/analytics/expectations/predict-cash-out.analytics';
+import { SPURS_PELICANS_POSITION_ID } from '../../api-mocking/mock-responses/polymarket/polymarket-constants';
/*
Test Scenario: Cash out on open position - Spurs vs. Pelicans
@@ -68,7 +69,9 @@ describe(SmokePredictions('Predictions'), () => {
await Assertions.expectElementToBeVisible(PredictDetailsPage.container);
await POLYMARKET_POST_CASH_OUT_MOCKS(mockServer);
- await PredictDetailsPage.tapCashOutButton();
+ await PredictDetailsPage.tapGameCashOutButton(
+ SPURS_PELICANS_POSITION_ID,
+ );
await Assertions.expectElementToBeVisible(PredictCashOutPage.container);
await Assertions.expectElementToBeVisible(
diff --git a/tests/smoke/predict/predict-geo-restriction.spec.ts b/tests/smoke/predict/predict-geo-restriction.spec.ts
index 2fcf87ec5a7..f690dd9d455 100644
--- a/tests/smoke/predict/predict-geo-restriction.spec.ts
+++ b/tests/smoke/predict/predict-geo-restriction.spec.ts
@@ -22,6 +22,9 @@ import {
POLYMARKET_GEO_BLOCKED_MOCKS,
} from '../../api-mocking/mock-responses/polymarket/polymarket-mocks';
import PredictAddFunds from '../../page-objects/Predict/PredictAddFunds';
+import { getEventsPayloads } from '../../helpers/analytics/helpers';
+import SoftAssert from '../../framework/SoftAssert';
+import { SPURS_PELICANS_POSITION_ID } from '../../api-mocking/mock-responses/polymarket/polymarket-constants';
import {
geoBlockedPredictActionExpectations,
geoBlockedCashoutExpectations,
@@ -119,7 +122,9 @@ describe(
await WalletView.scrollAndTapPredictionsPosition(
'Spurs vs. Pelicans',
);
- await PredictDetailsPage.tapCashOutButton();
+ await PredictDetailsPage.tapGameCashOutButton(
+ SPURS_PELICANS_POSITION_ID,
+ );
await PredictUnavailableView.expectVisible();
await PredictUnavailableView.tapGotIt();
diff --git a/tests/smoke/predict/predict-open-position.spec.ts b/tests/smoke/predict/predict-open-position.spec.ts
index 666bd5c12fa..1295e6c45c1 100644
--- a/tests/smoke/predict/predict-open-position.spec.ts
+++ b/tests/smoke/predict/predict-open-position.spec.ts
@@ -78,7 +78,7 @@ describe(SmokePredictions('Predictions'), () => {
positionDetails.category,
positionDetails.marketIndex,
);
- await PredictDetailsPage.tapOpenPositionValue();
+ await PredictDetailsPage.tapGameBetYesButton();
await POLYMARKET_POST_OPEN_POSITION_MOCKS(mockServer);
@@ -89,18 +89,6 @@ describe(SmokePredictions('Predictions'), () => {
await PredictDetailsPage.tapOpenPosition();
- await Assertions.expectElementToBeVisible(
- PredictDetailsPage.positionsTab,
- {
- description:
- 'Position tab should appear after opening a new position',
- },
- );
-
- await Assertions.expectTextDisplayed(positionDetails.name, {
- description: 'Position card for Celtics vs. Nets should appear',
- });
-
await PredictDetailsPage.tapBackButton();
await Assertions.expectTextDisplayed(positionDetails.newBalance, {
description: `USDC balance should display ${positionDetails.newBalance} after opening position`,
From e0d7d70aad3519e996e60ac3b0b254ced05a29b2 Mon Sep 17 00:00:00 2001
From: Prithpal Sooriya
Date: Thu, 7 May 2026 10:55:06 +0100
Subject: [PATCH 09/13] fix: show collection name in nft details (#29551)
## **Description**
Adds the missing collection name field to the NFT details screen. The
data already existed on `collectible.collection.name`, but the screen
did not render it.
## **Changelog**
CHANGELOG entry: fix: NFT details did not show the collection name
## **Related issues**
Fixes: https://github.com/MetaMask/metamask-mobile/issues/29362
https://consensyssoftware.atlassian.net/browse/ASSETS-3125
## **Manual testing steps**
```gherkin
Feature: NFT details collection metadata
Scenario: User views an NFT details page
Given the user has imported NFTs from a collection
When user opens the details screen for one NFT
Then the screen displays the collection name in the Collection section
```
## **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)
- [x] 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
Co-authored-by: Cursor Agent
---
app/components/Views/NftDetails/NftDetails.test.ts | 1 +
app/components/Views/NftDetails/NftDetails.tsx | 6 ++++++
2 files changed, 7 insertions(+)
diff --git a/app/components/Views/NftDetails/NftDetails.test.ts b/app/components/Views/NftDetails/NftDetails.test.ts
index c62a4cacea8..752958b9330 100644
--- a/app/components/Views/NftDetails/NftDetails.test.ts
+++ b/app/components/Views/NftDetails/NftDetails.test.ts
@@ -205,6 +205,7 @@ describe('NftDetails', () => {
);
expect(getByText(TEST_COLLECTIBLE.name)).toBeOnTheScreen();
+ expect(getByText(TEST_COLLECTIBLE.collection.name)).toBeOnTheScreen();
});
it('tracks NFT Details Opened event with mobile-nft-list source', () => {
diff --git a/app/components/Views/NftDetails/NftDetails.tsx b/app/components/Views/NftDetails/NftDetails.tsx
index e89661cd859..129c204e7ab 100644
--- a/app/components/Views/NftDetails/NftDetails.tsx
+++ b/app/components/Views/NftDetails/NftDetails.tsx
@@ -528,6 +528,12 @@ const NftDetails = () => {
) : null}
+
Date: Thu, 7 May 2026 12:41:40 +0200
Subject: [PATCH 10/13] chore: add money deposit hook (#29487)
## **Description**
## **Changelog**
CHANGELOG entry: Updated money account deposit.
## **Related issues**
Depends on: https://github.com/MetaMask/metamask-mobile/pull/29561
Depends on: https://github.com/MetaMask/core/pull/8687
## **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]
> **Medium Risk**
> Updates Money Account deposit transaction construction and
confirmation re-encoding, which can affect on-chain calldata and Pay
gating via `requiredAssets`, though it is still using a zero-amount
placeholder by default.
>
> **Overview**
> Adds a Money Account deposit initiation path that submits an
`addTransactionBatch` for approve+deposit with a **zero-amount
placeholder**, navigates to the `CustomAmount` confirmation screen, and
declares Pay `requiredAssets` using a shared deposit-asset helper.
>
> Implements real `updateMoneyAccountDepositTokenAmount` re-encoding:
converts the user-entered human amount to USDC base units, calls
`previewDeposit` to compute `minimumMint` (with slippage), and returns
updated calldata for the nested approve/deposit calls; it no-ops when
vault config/provider is unavailable.
>
> Refactors deposit asset handling into
`getMoneyAccountDepositAssetAddress` (currently hardcoded USDC), skips
the `previewDeposit` RPC for 0 amounts, updates/extends unit tests and
confirmation mocks, and bumps `@metamask/transaction-controller` to
`65.1.0`.
>
> Reviewed by [Cursor Bugbot](https://cursor.com/bugbot) for commit
5ef88041e09972d3998ddbede0c2c1001b399c25. Bugbot is set up for automated
code reviews on this repo. Configure
[here](https://www.cursor.com/dashboard/bugbot).
---------
Co-authored-by: Jyoti Puri
---
.../MoneyAddMoneySheet.test.tsx | 2 +-
.../MoneyAddMoneySheet/MoneyAddMoneySheet.tsx | 5 +-
.../UI/Money/hooks/useMoneyAccount.test.ts | 12 +-
.../UI/Money/hooks/useMoneyAccount.ts | 132 +++++++++---------
.../utils/moneyAccountTransactions.test.ts | 108 ++++++++++++--
.../Money/utils/moneyAccountTransactions.ts | 103 +++++++++++---
.../controllers/other-controllers-mock.ts | 11 ++
.../custom-amount-info.test.tsx | 17 +--
package.json | 2 +-
yarn.lock | 22 +--
10 files changed, 272 insertions(+), 142 deletions(-)
diff --git a/app/components/UI/Money/components/MoneyAddMoneySheet/MoneyAddMoneySheet.test.tsx b/app/components/UI/Money/components/MoneyAddMoneySheet/MoneyAddMoneySheet.test.tsx
index e0018003b4b..d78f8e0b638 100644
--- a/app/components/UI/Money/components/MoneyAddMoneySheet/MoneyAddMoneySheet.test.tsx
+++ b/app/components/UI/Money/components/MoneyAddMoneySheet/MoneyAddMoneySheet.test.tsx
@@ -149,7 +149,7 @@ describe('MoneyAddMoneySheet', () => {
);
expect(mockOnCloseBottomSheet).toHaveBeenCalledTimes(1);
- expect(mockInitiateDeposit).toHaveBeenCalledWith(BigInt(0));
+ expect(mockInitiateDeposit).toHaveBeenCalledWith();
});
it('closes the sheet when Move mUSD is pressed (interim, no flow wired yet)', () => {
diff --git a/app/components/UI/Money/components/MoneyAddMoneySheet/MoneyAddMoneySheet.tsx b/app/components/UI/Money/components/MoneyAddMoneySheet/MoneyAddMoneySheet.tsx
index c62a4bbda98..824a3a1fc1d 100644
--- a/app/components/UI/Money/components/MoneyAddMoneySheet/MoneyAddMoneySheet.tsx
+++ b/app/components/UI/Money/components/MoneyAddMoneySheet/MoneyAddMoneySheet.tsx
@@ -53,12 +53,9 @@ const MoneyAddMoneySheet: React.FC = () => {
navigation.goBack();
}, [navigation]);
- // TODO(MUSD-478/MUSD-516): point to the MM Pay "Add money" amount-entry
- // screen (Figma 2547:8887). Amount is collected by the MM Pay UI; the
- // placeholder 0n keeps the deposit pipeline wired until that lands.
const handleConvertCrypto = useCallback(() => {
closeAndNavigate(() => {
- initiateDeposit(BigInt(0)).catch(() => undefined);
+ initiateDeposit().catch(() => undefined);
});
}, [closeAndNavigate, initiateDeposit]);
diff --git a/app/components/UI/Money/hooks/useMoneyAccount.test.ts b/app/components/UI/Money/hooks/useMoneyAccount.test.ts
index a0d33349326..a87f90f67a6 100644
--- a/app/components/UI/Money/hooks/useMoneyAccount.test.ts
+++ b/app/components/UI/Money/hooks/useMoneyAccount.test.ts
@@ -158,7 +158,7 @@ describe('useMoneyAccountDeposit', () => {
await expect(
act(async () => {
- await result.current.initiateDeposit(BigInt(1_000_000));
+ await result.current.initiateDeposit();
}),
).rejects.toThrow('Missing vault config');
@@ -173,7 +173,7 @@ describe('useMoneyAccountDeposit', () => {
await expect(
act(async () => {
- await result.current.initiateDeposit(BigInt(1_000_000));
+ await result.current.initiateDeposit();
}),
).rejects.toThrow('No provider available');
@@ -184,12 +184,12 @@ describe('useMoneyAccountDeposit', () => {
const { result } = renderHook(() => useMoneyAccountDeposit());
await act(async () => {
- await result.current.initiateDeposit(BigInt(1_000_000));
+ await result.current.initiateDeposit();
});
expect(mockBuildDepositBatch).toHaveBeenCalledWith(
expect.objectContaining({
- amount: BigInt(1_000_000),
+ amount: BigInt(0),
chainId: MOCK_VAULT_CONFIG.chainId,
boringVault: MOCK_VAULT_CONFIG.boringVault,
}),
@@ -220,7 +220,7 @@ describe('useMoneyAccountDeposit', () => {
let caught: Error | undefined;
await act(async () => {
try {
- await result.current.initiateDeposit(BigInt(1_000_000));
+ await result.current.initiateDeposit();
} catch (error) {
caught = error as Error;
}
@@ -242,7 +242,7 @@ describe('useMoneyAccountDeposit', () => {
await expect(
act(async () => {
- await result.current.initiateDeposit(BigInt(1_000_000));
+ await result.current.initiateDeposit();
}),
).rejects.toThrow('Network client not found');
diff --git a/app/components/UI/Money/hooks/useMoneyAccount.ts b/app/components/UI/Money/hooks/useMoneyAccount.ts
index 18ad80bc3e7..6eb6b77f74c 100644
--- a/app/components/UI/Money/hooks/useMoneyAccount.ts
+++ b/app/components/UI/Money/hooks/useMoneyAccount.ts
@@ -12,6 +12,7 @@ import { selectPrimaryMoneyAccount } from '../../../../selectors/moneyAccountCon
import {
buildMoneyAccountDepositBatch,
buildMoneyAccountWithdraw,
+ getMoneyAccountDepositAssetAddress,
} from '../utils/moneyAccountTransactions';
import { getProviderByChainId } from '../../../../util/notifications/methods/common';
import Logger from '../../../../util/Logger';
@@ -36,74 +37,71 @@ export function useMoneyAccountDeposit() {
const primaryMoneyAccount = useSelector(selectPrimaryMoneyAccount);
const { navigateToConfirmation } = useConfirmNavigation();
- const initiateDeposit = useCallback(
- // TODO: remove the account parameter and instead of directly building approve and deposit transactions
- // we need to implemend a hook from `addTransactionBatch` from which we can get the user inputed amount
- // and then use that to build the approve and deposit transactions. This is because user inputs the amount
- // in the MM pay UI and we need to use that amount.
- async (amount: bigint) => {
- if (!vaultConfig) {
- throw new Error(`${LOG_TAG} Missing vault config`);
- }
- if (!primaryMoneyAccount?.address) {
- throw new Error(`${LOG_TAG} Missing money account address`);
- }
-
- const {
- chainId,
- boringVault,
- tellerAddress,
- accountantAddress,
- lensAddress,
- } = vaultConfig;
-
- const chainIdHex = chainId as Hex;
- const provider = getProviderByChainId(chainIdHex);
- if (!provider) {
- throw new Error(
- `${LOG_TAG} No provider available for chain ${chainId}`,
- );
- }
-
- const networkClientId = resolveNetworkClientId(chainIdHex);
-
- // TODO: as mentioned above this should move into hook from `addTransactionBatch`.
- const { approveTx, depositTx } = await buildMoneyAccountDepositBatch({
- amount,
- chainId: chainIdHex,
- boringVault,
- tellerAddress,
- accountantAddress,
- lensAddress,
- provider,
- });
-
- // Navigate early for better UX; recover on failure below.
- navigateToConfirmation({
- loader: ConfirmationLoader.CustomAmount,
- stack: Routes.MONEY.ROOT,
+ const initiateDeposit = useCallback(async () => {
+ if (!vaultConfig) {
+ throw new Error(`${LOG_TAG} Missing vault config`);
+ }
+ if (!primaryMoneyAccount?.address) {
+ throw new Error(`${LOG_TAG} Missing money account address`);
+ }
+
+ const {
+ chainId,
+ boringVault,
+ tellerAddress,
+ accountantAddress,
+ lensAddress,
+ } = vaultConfig;
+
+ const chainIdHex = chainId as Hex;
+ const provider = getProviderByChainId(chainIdHex);
+ if (!provider) {
+ throw new Error(`${LOG_TAG} No provider available for chain ${chainId}`);
+ }
+
+ const networkClientId = resolveNetworkClientId(chainIdHex);
+
+ const { approveTx, depositTx } = await buildMoneyAccountDepositBatch({
+ amount: BigInt(0),
+ chainId: chainIdHex,
+ boringVault,
+ tellerAddress,
+ accountantAddress,
+ lensAddress,
+ provider,
+ });
+
+ // Navigate early for better UX; recover on failure below.
+ navigateToConfirmation({
+ loader: ConfirmationLoader.CustomAmount,
+ stack: Routes.MONEY.ROOT,
+ });
+
+ try {
+ // We only set the transaction from the money account perspective.
+ // MM Pay selects the user's account and moves funds to the money account,
+ // so `from` must be the money account and `networkClientId` its chain.
+ await addTransactionBatch({
+ from: primaryMoneyAccount.address as Hex,
+ networkClientId,
+ origin: ORIGIN_METAMASK,
+ disableHook: true,
+ disableSequential: true,
+ transactions: [approveTx, depositTx],
+ requiredAssets: [
+ {
+ address: getMoneyAccountDepositAssetAddress(chainIdHex),
+ amount: '0x0' as Hex,
+ standard: 'erc20',
+ },
+ ],
});
-
- try {
- // We only set the transaction from the money account perspective.
- // MM Pay selects the user's account and moves funds to the money account,
- // so `from` must be the money account and `networkClientId` its chain.
- await addTransactionBatch({
- from: primaryMoneyAccount.address as Hex,
- networkClientId,
- origin: ORIGIN_METAMASK,
- disableHook: true,
- disableSequential: true,
- transactions: [approveTx, depositTx],
- });
- } catch (error) {
- Logger.error(error as Error, `${LOG_TAG} Deposit transaction failed`);
- // Rethrow so the caller can roll back navigation / surface a toast.
- throw error;
- }
- },
- [navigateToConfirmation, primaryMoneyAccount, vaultConfig],
- );
+ } catch (error) {
+ Logger.error(error as Error, `${LOG_TAG} Deposit transaction failed`);
+ // Rethrow so the caller can roll back navigation / surface a toast.
+ throw error;
+ }
+ }, [navigateToConfirmation, primaryMoneyAccount, vaultConfig]);
return { initiateDeposit };
}
diff --git a/app/components/UI/Money/utils/moneyAccountTransactions.test.ts b/app/components/UI/Money/utils/moneyAccountTransactions.test.ts
index 194ff65e887..7a1ce5f6d6a 100644
--- a/app/components/UI/Money/utils/moneyAccountTransactions.test.ts
+++ b/app/components/UI/Money/utils/moneyAccountTransactions.test.ts
@@ -14,6 +14,12 @@ import {
updateMoneyAccountDepositTokenAmount,
updateMoneyAccountWithdrawTokenAmount,
} from './moneyAccountTransactions';
+import {
+ type MoneyAccountVaultConfig,
+ selectMoneyAccountVaultConfig,
+} from '../../../../selectors/featureFlagController/moneyAccount';
+import { getProviderByChainId } from '../../../../util/notifications/methods/common';
+import ReduxService from '../../../../core/redux';
jest.mock('../../Earn/constants/musd', () => ({
MUSD_TOKEN_ADDRESS_BY_CHAIN: {} as Record,
@@ -26,6 +32,10 @@ jest.mock('../../../../core/AppConstants', () => ({
},
}));
+jest.mock('../../../../util/notifications/methods/common');
+jest.mock('../../../../core/redux');
+jest.mock('../../../../selectors/featureFlagController/moneyAccount');
+
const mockPreviewDeposit = jest.fn();
const mockGetRate = jest.fn();
@@ -50,6 +60,11 @@ jest.mock('ethers', () => {
};
});
+const mockGetProviderByChainId = jest.mocked(getProviderByChainId);
+const mockSelectMoneyAccountVaultConfig = jest.mocked(
+ selectMoneyAccountVaultConfig,
+);
+
const MOCK_CHAIN_ID = '0x1' as Hex;
const MOCK_MUSD_ADDRESS = '0xaca92e438df0b2401ff60da7e4337b687a2435da' as Hex;
const MOCK_BORING_VAULT = '0xB5F07d769dD60fE54c97dd53101181073DDf21b2' as Hex;
@@ -59,6 +74,14 @@ const MOCK_LENS = '0x846a7832022350434B5cC006d07cc9c782469660' as Hex;
const MOCK_TO_ADDRESS = '0x1234567890abcdef1234567890abcdef12345678' as Hex;
const MOCK_PROVIDER = {} as ethers.providers.Provider;
+const MOCK_VAULT_CONFIG: MoneyAccountVaultConfig = {
+ chainId: '0xa4b1',
+ boringVault: MOCK_BORING_VAULT,
+ tellerAddress: MOCK_TELLER,
+ accountantAddress: MOCK_ACCOUNTANT,
+ lensAddress: MOCK_LENS,
+};
+
describe('moneyAccountTransactions', () => {
beforeEach(() => {
jest.clearAllMocks();
@@ -178,28 +201,83 @@ describe('moneyAccountTransactions', () => {
});
describe('updateMoneyAccountDepositTokenAmount', () => {
- it('resolves to an empty array (stub implementation)', async () => {
- const transactionMeta = {
- id: 'tx-1',
- nestedTransactions: [],
- } as unknown as TransactionMeta;
+ const mockTransactionMeta = {
+ id: 'tx-1',
+ chainId: MOCK_VAULT_CONFIG.chainId as Hex,
+ } as unknown as TransactionMeta;
+
+ beforeEach(() => {
+ // Default: vault config present, provider present
+ mockGetProviderByChainId.mockReturnValue(MOCK_PROVIDER as never);
+ mockSelectMoneyAccountVaultConfig.mockReturnValue(MOCK_VAULT_CONFIG);
+ (
+ jest.mocked(ReduxService) as unknown as {
+ store: { getState: jest.Mock };
+ }
+ ).store = { getState: jest.fn().mockReturnValue({}) };
+ });
- await expect(
- updateMoneyAccountDepositTokenAmount(transactionMeta, '1.23'),
- ).resolves.toEqual([]);
+ it('returns indexed approve and deposit calls for a valid amount', async () => {
+ mockPreviewDeposit.mockResolvedValue(ethers.BigNumber.from('1000000'));
+
+ const result = await updateMoneyAccountDepositTokenAmount(
+ mockTransactionMeta,
+ '1.0',
+ );
+
+ expect(result).toHaveLength(2);
+ expect(result[0].nestedTransactionIndex).toBe(0);
+ expect(result[0].transactionData).toMatch(/^0x/);
+ expect(result[1].nestedTransactionIndex).toBe(1);
+ expect(result[1].transactionData).toMatch(/^0x/);
});
- it('resolves to an array regardless of transactionMeta shape', async () => {
- const transactionMeta = {
- id: 'tx-2',
- } as unknown as TransactionMeta;
+ it('calls previewDeposit with the converted amount', async () => {
+ mockPreviewDeposit.mockResolvedValue(ethers.BigNumber.from('1000000'));
+
+ await updateMoneyAccountDepositTokenAmount(mockTransactionMeta, '1.0');
+
+ // 1.0 USDC with 6 decimals = 1_000_000
+ expect(mockPreviewDeposit).toHaveBeenCalledWith(
+ expect.any(String),
+ '1000000',
+ MOCK_VAULT_CONFIG.boringVault,
+ MOCK_VAULT_CONFIG.accountantAddress,
+ );
+ });
+
+ it('returns [] when vault config is missing', async () => {
+ mockSelectMoneyAccountVaultConfig.mockReturnValue(undefined);
const result = await updateMoneyAccountDepositTokenAmount(
- transactionMeta,
- '1.23',
+ mockTransactionMeta,
+ '1.0',
);
- expect(Array.isArray(result)).toBe(true);
+ expect(result).toEqual([]);
+ expect(mockGetProviderByChainId).not.toHaveBeenCalled();
+ expect(mockPreviewDeposit).not.toHaveBeenCalled();
+ });
+
+ it('returns [] when provider is missing', async () => {
+ mockGetProviderByChainId.mockReturnValue(undefined as never);
+
+ const result = await updateMoneyAccountDepositTokenAmount(
+ mockTransactionMeta,
+ '1.0',
+ );
+
+ expect(result).toEqual([]);
+ expect(mockPreviewDeposit).not.toHaveBeenCalled();
+ });
+
+ it('rejects when previewDeposit fails so the dispatcher can log the error', async () => {
+ const rpcError = new Error('RPC connection refused');
+ mockPreviewDeposit.mockRejectedValue(rpcError);
+
+ await expect(
+ updateMoneyAccountDepositTokenAmount(mockTransactionMeta, '1.0'),
+ ).rejects.toThrow('RPC connection refused');
});
});
diff --git a/app/components/UI/Money/utils/moneyAccountTransactions.ts b/app/components/UI/Money/utils/moneyAccountTransactions.ts
index 9dc8f777a02..76fef2d8388 100644
--- a/app/components/UI/Money/utils/moneyAccountTransactions.ts
+++ b/app/components/UI/Money/utils/moneyAccountTransactions.ts
@@ -1,4 +1,5 @@
import { ethers } from 'ethers';
+import { BigNumber } from 'bignumber.js';
import {
TransactionMeta,
TransactionType,
@@ -8,6 +9,11 @@ import { Hex } from '@metamask/utils';
import { UpdateTransactionPayAmountCall } from '../../../Views/confirmations/types/transactions';
import { MUSD_TOKEN_ADDRESS_BY_CHAIN } from '../../Earn/constants/musd';
import AppConstants from '../../../../core/AppConstants';
+import { calcTokenValue } from '../../../../util/transactions';
+import { getProviderByChainId } from '../../../../util/notifications/methods/common';
+import { selectMoneyAccountVaultConfig } from '../../../../selectors/featureFlagController/moneyAccount';
+import ReduxService from '../../../../core/redux';
+import type { RootState } from '../../../../reducers';
const LENS_ABI = [
'function previewDeposit(address depositAsset, uint256 depositAmount, address boringVault, address accountant) view returns (uint256 shares)',
@@ -95,6 +101,24 @@ function buildDepositData(
]) as Hex;
}
+/**
+ * Single source of truth for the deposit asset so both calldata encoding
+ * (`buildMoneyAccountDepositBatch`) and Pay's `requiredAssets` agree.
+ * @param _chainId - The chain ID to get the deposit asset address for.
+ * @returns The deposit asset address for the given chain ID.
+ */
+// eslint-disable-next-line @typescript-eslint/no-unused-vars
+export function getMoneyAccountDepositAssetAddress(chainId: Hex): Hex {
+ // TODO: uncomment when mUSD is deployed
+ // const musdAddress = MUSD_TOKEN_ADDRESS_BY_CHAIN[_chainId];
+ // if (!musdAddress) {
+ // throw new Error(`mUSD not deployed on chain ${_chainId}`);
+ // }
+ // return musdAddress;
+ // TODO: remove when mUSD is deployed - temporarily hardcoded USDC
+ return '0xaf88d065e77c8cC2239327C5EDb3A432268e5831';
+}
+
export interface MoneyAccountDepositBatchResult {
approveTx: MoneyAccountTxParams;
depositTx: MoneyAccountTxParams;
@@ -125,24 +149,23 @@ export async function buildMoneyAccountDepositBatch({
lensAddress: string;
provider: ethers.providers.Provider;
}): Promise {
- // TODO: uncomment when mUSD is deployed
- // const musdAddress = MUSD_TOKEN_ADDRESS_BY_CHAIN[chainId];
- // if (!musdAddress) {
- // throw new Error(`mUSD not deployed on chain ${chainId}`);
- // }
- // TODO: remove when mUSD is deployed - temporarily hardcoded USDC
- const musdAddress = '0xaf88d065e77c8cC2239327C5EDb3A432268e5831';
+ const musdAddress = getMoneyAccountDepositAssetAddress(chainId);
- const expectedShares = await getExpectedDepositShares({
- lensAddress,
- boringVault,
- accountantAddress,
- musdAddress,
- amount,
- provider,
- });
+ // Skip the RPC call for zero-amount placeholder batches (e.g. initial deposit submission).
+ const minimumMint =
+ amount === 0n
+ ? 0n
+ : applySlippage(
+ await getExpectedDepositShares({
+ lensAddress,
+ boringVault,
+ accountantAddress,
+ musdAddress,
+ amount,
+ provider,
+ }),
+ );
- const minimumMint = applySlippage(expectedShares);
const approveData = buildApproveData(boringVault, amount);
const depositData = buildDepositData(musdAddress, amount, minimumMint);
@@ -166,20 +189,54 @@ export async function buildMoneyAccountDepositBatch({
};
}
+/** Decimals for USDC (the deposit asset). */
+const USDC_DECIMALS = 6;
+
/**
* Returns the per-nested-call data updates required when the user changes
* the deposit amount on a Money Account deposit confirmation.
*
- * Stub implementation — real encoding will replace this once the deposit
- * batch re-encoding logic is wired in.
+ * Reads vault config from the Redux store, calls `previewDeposit` on the
+ * lens contract to derive an accurate `minimumMint`, and returns the
+ * re-encoded approve + deposit calldata ready for `updateAtomicBatchData`.
+ *
+ * Returns `[]` (no-op) if vault config or provider is unavailable.
+ * Lets `buildMoneyAccountDepositBatch` errors propagate so the dispatcher
+ * can log them via its prep-error handler.
*/
-// eslint-disable-next-line @typescript-eslint/no-unused-vars
export async function updateMoneyAccountDepositTokenAmount(
- _transactionMeta: TransactionMeta,
- // eslint-disable-next-line @typescript-eslint/no-unused-vars
- _amountHuman: string,
+ transactionMeta: TransactionMeta,
+ amountHuman: string,
): Promise {
- return [];
+ const vaultConfig = selectMoneyAccountVaultConfig(
+ ReduxService.store.getState() as RootState,
+ );
+ if (!vaultConfig) return [];
+
+ const chainIdHex = transactionMeta.chainId as Hex;
+ const provider = getProviderByChainId(chainIdHex);
+ if (!provider) return [];
+
+ const amount = BigInt(
+ calcTokenValue(amountHuman, USDC_DECIMALS)
+ .decimalPlaces(0, BigNumber.ROUND_UP)
+ .toFixed(0),
+ );
+
+ const { approveTx, depositTx } = await buildMoneyAccountDepositBatch({
+ amount,
+ chainId: chainIdHex,
+ boringVault: vaultConfig.boringVault,
+ tellerAddress: vaultConfig.tellerAddress,
+ accountantAddress: vaultConfig.accountantAddress,
+ lensAddress: vaultConfig.lensAddress,
+ provider,
+ });
+
+ return [
+ { nestedTransactionIndex: 0, transactionData: approveTx.params.data },
+ { nestedTransactionIndex: 1, transactionData: depositTx.params.data },
+ ];
}
/**
diff --git a/app/components/Views/confirmations/__mocks__/controllers/other-controllers-mock.ts b/app/components/Views/confirmations/__mocks__/controllers/other-controllers-mock.ts
index b70878397ea..a420d225105 100644
--- a/app/components/Views/confirmations/__mocks__/controllers/other-controllers-mock.ts
+++ b/app/components/Views/confirmations/__mocks__/controllers/other-controllers-mock.ts
@@ -234,6 +234,16 @@ export const gasFeeControllerMock = {
},
};
+export const moneyAccountControllerMock = {
+ engine: {
+ backgroundState: {
+ MoneyAccountController: {
+ moneyAccounts: {},
+ },
+ },
+ },
+};
+
export const predictControllerMock = {
engine: {
backgroundState: {
@@ -400,5 +410,6 @@ export const otherControllersMock = merge(
tokenRatesControllerMock,
tokensControllerMock,
gasFeeControllerMock,
+ moneyAccountControllerMock,
predictControllerMock,
);
diff --git a/app/components/Views/confirmations/components/info/custom-amount-info/custom-amount-info.test.tsx b/app/components/Views/confirmations/components/info/custom-amount-info/custom-amount-info.test.tsx
index 09fcccc8e33..6983f5e8096 100644
--- a/app/components/Views/confirmations/components/info/custom-amount-info/custom-amount-info.test.tsx
+++ b/app/components/Views/confirmations/components/info/custom-amount-info/custom-amount-info.test.tsx
@@ -68,20 +68,6 @@ jest.mock('../../../hooks/pay/useTransactionPayWithdraw', () => ({
jest.mock('../../../hooks/transactions/useTransactionAccountOverride');
jest.mock('../../../../../../util/transaction-controller', () => ({}));
jest.mock('../../../../../../util/Logger');
-jest.mock('../../../../../UI/Money/hooks/useMoneyAccountBalance', () => ({
- __esModule: true,
- default: () => ({
- vaultApyQuery: { data: { apy: 5.5 }, isLoading: false },
- }),
-}));
-jest.mock(
- '../../../../../UI/SimulationDetails/FiatDisplay/useFiatFormatter',
- () => ({
- __esModule: true,
- default: () => (value: { toString: () => string }) =>
- `$${Number(value.toString()).toFixed(2)}`,
- }),
-);
jest.mock('../../../../../../core/Engine', () => ({
context: {
TransactionPayController: {
@@ -96,6 +82,9 @@ jest.mock('../../PayAccountSelector', () => {
default: () => ,
};
});
+jest.mock('../../projected-five-year-balance', () => ({
+ ProjectedFiveYearBalance: () => null,
+}));
jest.mock('../../../hooks/metrics/useConfirmationAlertMetrics', () => ({
useConfirmationAlertMetrics: () => ({
trackInlineAlertClicked: jest.fn(),
diff --git a/package.json b/package.json
index 10f326383ec..ee0dc720a32 100644
--- a/package.json
+++ b/package.json
@@ -342,7 +342,7 @@
"@metamask/storage-service": "^1.0.0",
"@metamask/superstruct": "^3.2.1",
"@metamask/swappable-obj-proxy": "^2.1.0",
- "@metamask/transaction-controller": "^65.0.0",
+ "@metamask/transaction-controller": "^65.1.0",
"@metamask/transaction-pay-controller": "^21.0.0",
"@metamask/tron-wallet-snap": "^1.25.3",
"@metamask/utils": "^11.11.0",
diff --git a/yarn.lock b/yarn.lock
index 2409d5e4fd6..acb0a88ee05 100644
--- a/yarn.lock
+++ b/yarn.lock
@@ -9126,8 +9126,8 @@ __metadata:
linkType: hard
"@metamask/messenger@npm:^1.0.0, @metamask/messenger@npm:^1.1.0":
- version: 1.1.1
- resolution: "@metamask/messenger@npm:1.1.1"
+ version: 1.2.0
+ resolution: "@metamask/messenger@npm:1.2.0"
dependencies:
"@metamask/utils": "npm:^11.9.0"
yargs: "npm:^17.7.2"
@@ -9135,7 +9135,7 @@ __metadata:
typescript: ">=5.0.0"
bin:
messenger-generate-action-types: ./dist/generate-action-types/cli.mjs
- checksum: 10/a959af95e9e117aa0f7ad1c280f7817fef2c0b575c76837b1a6c884c9c9ef1dd0faeaef0c2c0c2035f68c7638d1f87cd172956ee962dec97d8ab6176fa6964e3
+ checksum: 10/6818e4609d6162a436cc07955905f9e57ff6dbef841e9066a5fb9cc0538e981526fbcb5eef1fa1968d79212d57ddda2fce4dda5f87eb64d8d98f7db1216a6a98
languageName: node
linkType: hard
@@ -10298,9 +10298,9 @@ __metadata:
languageName: node
linkType: hard
-"@metamask/transaction-controller@npm:^65.0.0":
- version: 65.0.0
- resolution: "@metamask/transaction-controller@npm:65.0.0"
+"@metamask/transaction-controller@npm:^65.0.0, @metamask/transaction-controller@npm:^65.1.0":
+ version: 65.1.0
+ resolution: "@metamask/transaction-controller@npm:65.1.0"
dependencies:
"@ethereumjs/common": "npm:^4.4.0"
"@ethereumjs/tx": "npm:^5.4.0"
@@ -10309,15 +10309,15 @@ __metadata:
"@ethersproject/contracts": "npm:^5.7.0"
"@ethersproject/providers": "npm:^5.7.0"
"@ethersproject/wallet": "npm:^5.7.0"
- "@metamask/accounts-controller": "npm:^37.2.0"
+ "@metamask/accounts-controller": "npm:^38.0.0"
"@metamask/approval-controller": "npm:^9.0.1"
"@metamask/base-controller": "npm:^9.1.0"
"@metamask/controller-utils": "npm:^11.20.0"
"@metamask/core-backend": "npm:^6.2.1"
"@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/nonce-tracker": "npm:^6.0.0"
"@metamask/remote-feature-flag-controller": "npm:^4.2.0"
"@metamask/rpc-errors": "npm:^7.0.2"
@@ -10332,7 +10332,7 @@ __metadata:
peerDependencies:
"@babel/runtime": ^7.0.0
"@metamask/eth-block-tracker": ">=9"
- checksum: 10/b8d330c5169068aafc59b757d97d9f2a3ab62d93ff9c03d93974be804aa886ef4277707e6f79be392b7cf3f4f7429e93681f1aac3ad17644aae35aa5e6deacaa
+ checksum: 10/109c4220bdbec0fc1434728cca165948e5231e397ea1836f3146be2b45145a208b496451a175b819ec9d8ae401b5e03a7ac0a13bf1d5c681392d0ad64bd1419f
languageName: node
linkType: hard
@@ -35742,7 +35742,7 @@ __metadata:
"@metamask/test-dapp": "npm:9.5.0"
"@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-controller": "npm:^65.1.0"
"@metamask/transaction-pay-controller": "npm:^21.0.0"
"@metamask/tron-wallet-snap": "npm:^1.25.3"
"@metamask/utils": "npm:^11.11.0"
From e34c66e1d2d460029a9b817911572a9e3a5108fe Mon Sep 17 00:00:00 2001
From: =?UTF-8?q?Jos=C3=A9=20Manuel?=
<6741785+jluque0101@users.noreply.github.com>
Date: Thu, 7 May 2026 13:12:50 +0200
Subject: [PATCH 11/13] ci: namespace runner trial control plane (#29557)
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
## **Description**
INFRA-3592 Phase 0 — adds a `runner_provider` control input (`current` |
`namespace`, default `current`) to the Phase 1-4 entry-point and
reusable workflows, threads forwarding through every in-scope caller,
and wraps each in-scope `runs-on:` in an additive ternary that selects
between the existing runner and the matching `namespace-profile-*`
label.
**No job is migrated. No default is changed.** With `runner_provider:
current` (the default on every existing trigger) every ternary collapses
to its prior literal/expression, so behavior is byte-identical to
`main`. The `namespace` branch is reachable only via manual
`workflow_dispatch`.
Branch is the long-lived working surface for Phases 1-4; do not merge.
## **Changelog**
CHANGELOG entry: null
## **Related issues**
Fixes:
[INFRA-3592](https://consensyssoftware.atlassian.net/browse/INFRA-3592)
(parent epic INFRA-3511)
## **Manual testing steps**
```gherkin
Feature: runner_provider trial control plane
Scenario: dispatch on the current default — byte-identical
Given the trial branch namespace-runner-trial
When user runs `gh workflow run ci.yml --ref namespace-runner-trial -f runner_provider=current`
Then every job runs on its existing GitHub-hosted / Cirrus runner
And required-check context names match origin/main exactly
Scenario: dispatch on namespace — plumbing reaches Namespace
Given the trial branch and the MetaMask Actions allowlist already updated
When user runs `gh workflow run ci.yml --ref namespace-runner-trial -f runner_provider=namespace`
Then jobs are picked up by the namespace-profile-metamask-* runner that maps to each existing class
And end-to-end success is not required at Phase 0 — composite-action assumptions land in Phase 2-4
```
## **Verification evidence (already executed)**
Both scenarios above were dispatched against this branch — results
recorded:
| Scenario | Run | Result |
| --- | --- | --- |
| `runner_provider=namespace` (Namespace path) |
[25319648133](https://github.com/MetaMask/metamask-mobile/actions/runs/25319648133)
| Linux, iOS, Android jobs all picked up by the matching
`namespace-profile-metamask-*` runner; secrets resolved end-to-end
(`secrets: inherit` flowing); job names + required-check contexts
unchanged |
| `runner_provider=current` (existing runners) |
[25320925061](https://github.com/MetaMask/metamask-mobile/actions/runs/25320925061)
| 0 Namespace instances spawned in the dispatch window; every job ran on
its prior GitHub-hosted / Cirrus runner |
| Implicit `current` via PR-trigger (no input) |
[25174041735](https://github.com/MetaMask/metamask-mobile/actions/runs/25174041735),
[25162883313](https://github.com/MetaMask/metamask-mobile/actions/runs/25162883313)
| Both `pull_request` runs on the branch completed successfully on
existing runners — proves the byte-identical contract holds without a
manual dispatch |
Required-check parity verified statically against the 3 contexts on
`main`'s branch protection (`check-template-and-add-labels`, `Check all
jobs pass`, `CLABot`) — none renamed in this diff. Detailed
cross-reference in INFRA-3592 [comment 417866
§5](https://consensyssoftware.atlassian.net/browse/INFRA-3592).
## **Screenshots/Recordings**
N/A — CI infrastructure PR, no UI surface.
## **Notes for reviewers**
- 6 commits, organised so each is independently reviewable: actionlint
label registration → `workflow_dispatch` inputs → `workflow_call` inputs
→ caller forwarding → `runs-on` ternary → placeholder→canonical-label
replacement.
- Phase 7 callers (`runway-*`, `nightly-build`,
`build-and-upload-to-testflight`, `push-eas-update`, `build-rc-auto`)
are intentionally **not** modified — they continue to call without
forwarding, callees default to `current`.
- Composite actions are inventoried only — Phase 2/3/4/5/7 own their
migration.
- `actionlint -config-file .github/actionlint.yaml` produces
byte-identical output to `origin/main` (84 lines, exit 1 from
pre-existing warnings only — no new findings introduced by this PR).
- Inventories captured (composite actions, caller graph,
secrets/environments, concurrency groups, required-check contexts) —
full tables in INFRA-3592 [comment
417866](https://consensyssoftware.atlassian.net/browse/INFRA-3592).
- Branch was rebased onto current `main` after a conflict with
[#29431](https://github.com/MetaMask/metamask-mobile/pull/29431) (e2e
label rename). Conflict resolution was mechanical — main's renamed jobs
(swap-, stake-, money-) had `runner_provider:` re-applied; no semantic
decision involved.
## **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).
- [x] 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
- [x] I've applied the right labels on the PR (`team-dev-ops`, `size-M`)
#### Performance checks (if applicable)
N/A — workflow YAML only, no app code.
## **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.
[INFRA-3592]:
https://consensyssoftware.atlassian.net/browse/INFRA-3592?atlOrigin=eyJpIjoiNWRkNTljNzYxNjVmNDY3MDlhMDU5Y2ZhYzA5YTRkZjUiLCJwIjoiZ2l0aHViLWNvbS1KU1cifQ
---
> [!NOTE]
> **Medium Risk**
> Touches many GitHub Actions workflows and `runs-on` expressions, so
miswiring could break CI execution or route jobs to the wrong runner.
Default behavior remains `current`, but the new `namespace` path changes
execution environment when manually dispatched.
>
> **Overview**
> Introduces a new `runner_provider` input (default `current`, optional
`namespace`) across in-scope entrypoint and reusable workflows, and
forwards it through callers.
>
> Updates `runs-on` in `ci.yml`, `build.yml`, `setup-node-modules.yml`,
and E2E build/test workflows to conditionally select between existing
GitHub-hosted/Cirrus runners and new `namespace-profile-metamask-*`
runner labels.
>
> Registers the new `namespace-profile-*` labels in
`.github/actionlint.yaml`, and adds `workflow_dispatch` inputs to enable
manual trial runs using the namespace provider.
>
> Reviewed by [Cursor Bugbot](https://cursor.com/bugbot) for commit
fffcc88e6fce34a2bef43c9012d7b3a0fd9c09b8. Bugbot is set up for automated
code reviews on this repo. Configure
[here](https://www.cursor.com/dashboard/bugbot).
---
.github/actionlint.yaml | 5 ++
.github/workflows/build-android-e2e.yml | 7 ++-
.github/workflows/build-ios-e2e.yml | 7 ++-
.github/workflows/build.yml | 20 ++++++--
.github/workflows/ci.yml | 50 ++++++++++++-------
.github/workflows/expo-dev-build.yml | 10 ++++
.../run-e2e-regression-tests-android.yml | 19 ++++++-
.../run-e2e-regression-tests-ios.yml | 20 +++++++-
.../workflows/run-e2e-smoke-tests-android.yml | 22 +++++++-
.github/workflows/run-e2e-smoke-tests-ios.yml | 22 +++++++-
.github/workflows/run-e2e-workflow.yml | 7 ++-
.github/workflows/setup-node-modules.yml | 7 ++-
12 files changed, 168 insertions(+), 28 deletions(-)
diff --git a/.github/actionlint.yaml b/.github/actionlint.yaml
index 39f1b05384f..d54ce5cb39b 100644
--- a/.github/actionlint.yaml
+++ b/.github/actionlint.yaml
@@ -15,6 +15,11 @@ self-hosted-runner:
- "ghcr.io/cirruslabs/ubuntu-runner-amd64:24.04-lg"
- "ghcr.io/cirruslabs/ubuntu-runner-amd64:24.04-xl"
- "low-priority"
+ # Namespace runner profile labels (INFRA-3592). Format: namespace-profile-.
+ - "namespace-profile-metamask-ci-linux"
+ - "namespace-profile-metamask-android-build"
+ - "namespace-profile-metamask-ios-build"
+ - "namespace-profile-metamask-ios-e2e"
# Configuration variables in array of strings defined in your repository or
# organization. `null` means disabling configuration variables check.
diff --git a/.github/workflows/build-android-e2e.yml b/.github/workflows/build-android-e2e.yml
index f39ecff8995..ee847f40a7b 100644
--- a/.github/workflows/build-android-e2e.yml
+++ b/.github/workflows/build-android-e2e.yml
@@ -25,11 +25,16 @@ on:
required: false
default: 'qa'
type: string
+ runner_provider:
+ description: Runner provider forwarded from the caller
+ required: false
+ type: string
+ default: current
jobs:
build-android-apks:
name: Build Android E2E APKs
- runs-on: ghcr.io/cirruslabs/ubuntu-runner-amd64:24.04-lg # Optimized for lg runner (48GB) with conservative memory settings
+ runs-on: ${{ inputs.runner_provider == 'namespace' && 'namespace-profile-metamask-android-build' || 'ghcr.io/cirruslabs/ubuntu-runner-amd64:24.04-lg' }} # Optimized for lg runner (48GB) with conservative memory settings
timeout-minutes: 40
env:
GRADLE_USER_HOME: /home/admin/_work/.gradle
diff --git a/.github/workflows/build-ios-e2e.yml b/.github/workflows/build-ios-e2e.yml
index aa2296a5406..c3fd9f4420a 100644
--- a/.github/workflows/build-ios-e2e.yml
+++ b/.github/workflows/build-ios-e2e.yml
@@ -17,6 +17,11 @@ on:
required: false
default: 'qa'
type: string
+ runner_provider:
+ description: Runner provider forwarded from the caller
+ required: false
+ type: string
+ default: current
permissions:
contents: read
@@ -25,7 +30,7 @@ permissions:
jobs:
build-ios-apps:
name: Build iOS E2E Apps
- runs-on: ghcr.io/cirruslabs/macos-runner:tahoe-xl
+ runs-on: ${{ inputs.runner_provider == 'namespace' && 'namespace-profile-metamask-ios-build' || 'ghcr.io/cirruslabs/macos-runner:tahoe-xl' }}
outputs:
artifacts-url: ${{ steps.set-artifacts-url.outputs.artifacts-url }}
app-uploaded: ${{ steps.upload-app.outcome == 'success' }}
diff --git a/.github/workflows/build.yml b/.github/workflows/build.yml
index eb7be66eb01..b779012fce1 100644
--- a/.github/workflows/build.yml
+++ b/.github/workflows/build.yml
@@ -25,6 +25,11 @@ on:
required: false
type: boolean
default: false
+ runner_provider:
+ description: Runner provider forwarded from the caller
+ required: false
+ type: string
+ default: current
outputs:
build_name:
description: 'build_name input passed to this workflow'
@@ -81,6 +86,14 @@ on:
required: false
type: boolean
default: false
+ runner_provider:
+ description: Runner provider for this manual trial run
+ required: true
+ type: choice
+ options:
+ - current
+ - namespace
+ default: current
permissions:
contents: read
@@ -104,7 +117,7 @@ jobs:
prepare:
needs: [update-build-version]
if: ${{ always() && !failure() && !cancelled() }}
- runs-on: ubuntu-latest
+ runs-on: ${{ inputs.runner_provider == 'namespace' && 'namespace-profile-metamask-ci-linux' || 'ubuntu-latest' }}
outputs:
github_environment: ${{ steps.config.outputs.github_environment }}
secrets_json: ${{ steps.config.outputs.secrets_json }}
@@ -164,6 +177,7 @@ jobs:
upload-artifact: true
artifact-name: node-modules-${{ inputs.build_name }}-${{ matrix.platform }}
artifact-retention-days: 1
+ runner_provider: ${{ inputs.runner_provider }}
# Build
build:
@@ -174,7 +188,7 @@ jobs:
matrix:
platform: ${{ inputs.platform == 'both' && fromJSON('["android", "ios"]') || fromJSON(format('["{0}"]', inputs.platform)) }}
# Android: Cirrus lg (large) runner for 8GB Gradle heap; iOS: Cirrus macOS Tahoe (has Xcode 26.x)
- runs-on: ${{ matrix.platform == 'ios' && 'ghcr.io/cirruslabs/macos-runner:tahoe-xl' || 'ghcr.io/cirruslabs/ubuntu-runner-amd64:24.04-lg' }}
+ runs-on: ${{ inputs.runner_provider == 'namespace' && (matrix.platform == 'ios' && 'namespace-profile-metamask-ios-build' || 'namespace-profile-metamask-android-build') || (matrix.platform == 'ios' && 'ghcr.io/cirruslabs/macos-runner:tahoe-xl' || 'ghcr.io/cirruslabs/ubuntu-runner-amd64:24.04-lg') }}
environment: ${{ needs.prepare.outputs.github_environment }}
steps:
- name: Validate version-bump commit
@@ -517,7 +531,7 @@ jobs:
name: Emit build metadata
needs: [prepare, build]
if: ${{ !failure() && !cancelled() && needs.prepare.result == 'success' && needs.build.result == 'success' }}
- runs-on: ubuntu-latest
+ runs-on: ${{ inputs.runner_provider == 'namespace' && 'namespace-profile-metamask-ci-linux' || 'ubuntu-latest' }}
outputs:
checkout_ref: ${{ steps.meta.outputs.checkout_ref }}
built_commit_sha: ${{ steps.meta.outputs.built_commit_sha }}
diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml
index 139886e7570..8286e156322 100644
--- a/.github/workflows/ci.yml
+++ b/.github/workflows/ci.yml
@@ -15,6 +15,16 @@ on:
# Run the full suite "overnight," once every hour from 2:00am UTC until 6:00am UTC.
# This helps to identy the flaky and failed tests on main branch
- cron: '0 2-6 * * *'
+ workflow_dispatch:
+ inputs:
+ runner_provider:
+ description: Runner provider for this manual trial run
+ required: true
+ type: choice
+ options:
+ - current
+ - namespace
+ default: current
concurrency:
group: ${{ github.workflow }}-${{ github.ref == 'refs/heads/main' && github.sha || github.ref }}
@@ -27,7 +37,7 @@ jobs:
check-diff:
name: Check diff
- runs-on: macos-latest
+ runs-on: ${{ inputs.runner_provider == 'namespace' && 'namespace-profile-metamask-ios-build' || 'macos-latest' }}
if: ${{ needs.get_requirements.outputs.skip_everything != 'true' }}
needs:
- get_requirements
@@ -78,7 +88,7 @@ jobs:
dedupe:
name: Dedupe
- runs-on: ubuntu-latest
+ runs-on: ${{ inputs.runner_provider == 'namespace' && 'namespace-profile-metamask-ci-linux' || 'ubuntu-latest' }}
if: ${{ needs.get_requirements.outputs.skip_everything != 'true' }}
needs:
- get_requirements
@@ -114,7 +124,7 @@ jobs:
git-safe-dependencies:
name: Run `@lavamoat/git-safe-dependencies`
- runs-on: ubuntu-latest
+ runs-on: ${{ inputs.runner_provider == 'namespace' && 'namespace-profile-metamask-ci-linux' || 'ubuntu-latest' }}
if: ${{ needs.get_requirements.outputs.skip_everything != 'true' }}
needs:
- get_requirements
@@ -143,7 +153,7 @@ jobs:
scripts:
name: Run `${{ matrix.scripts }}`
- runs-on: ubuntu-latest
+ runs-on: ${{ inputs.runner_provider == 'namespace' && 'namespace-profile-metamask-ci-linux' || 'ubuntu-latest' }}
if: ${{ needs.get_requirements.outputs.skip_everything != 'true' }}
needs:
- get_requirements
@@ -186,7 +196,7 @@ jobs:
js-bundle-size-check:
name: JS bundle size check
- runs-on: ubuntu-latest
+ runs-on: ${{ inputs.runner_provider == 'namespace' && 'namespace-profile-metamask-ci-linux' || 'ubuntu-latest' }}
if: ${{ needs.get_requirements.outputs.skip_everything != 'true' }}
needs:
- get_requirements
@@ -396,7 +406,7 @@ jobs:
ship-js-bundle-size-check:
name: Ship JS bundle size check
- runs-on: ubuntu-latest
+ runs-on: ${{ inputs.runner_provider == 'namespace' && 'namespace-profile-metamask-ci-linux' || 'ubuntu-latest' }}
needs: [js-bundle-size-check]
if: ${{ github.ref == 'refs/heads/main' }}
steps:
@@ -434,7 +444,7 @@ jobs:
check-workflows:
name: Check workflows
- runs-on: ubuntu-latest
+ runs-on: ${{ inputs.runner_provider == 'namespace' && 'namespace-profile-metamask-ci-linux' || 'ubuntu-latest' }}
if: ${{ needs.get_requirements.outputs.skip_everything != 'true' }}
needs:
- get_requirements
@@ -450,7 +460,7 @@ jobs:
unit-tests:
name: Unit tests (${{ matrix.shard }})
- runs-on: ubuntu-latest
+ runs-on: ${{ inputs.runner_provider == 'namespace' && 'namespace-profile-metamask-ci-linux' || 'ubuntu-latest' }}
if: ${{ needs.get_requirements.outputs.skip_everything != 'true' }}
needs:
- get_requirements
@@ -505,7 +515,7 @@ jobs:
# We need to merge both unit and component view tests into a single coverage report so the PR coverage
# threshold calculation is accurate.
merge-unit-and-component-view-tests:
- runs-on: ubuntu-latest
+ runs-on: ${{ inputs.runner_provider == 'namespace' && 'namespace-profile-metamask-ci-linux' || 'ubuntu-latest' }}
needs: [unit-tests, component-view-tests]
if: ${{ !cancelled() && github.event_name != 'merge_group' }}
steps:
@@ -622,7 +632,7 @@ jobs:
component-view-tests:
name: Component view tests
- runs-on: ubuntu-latest
+ runs-on: ${{ inputs.runner_provider == 'namespace' && 'namespace-profile-metamask-ci-linux' || 'ubuntu-latest' }}
if: ${{ needs.get_requirements.outputs.skip_everything != 'true' }}
needs:
- get_requirements
@@ -678,7 +688,7 @@ jobs:
smart-e2e-selection:
name: 'Smart E2E Selection'
- runs-on: ubuntu-latest
+ runs-on: ${{ inputs.runner_provider == 'namespace' && 'namespace-profile-metamask-ci-linux' || 'ubuntu-latest' }}
if: ${{ needs.get_requirements.outputs.run_smart_e2e_selection == 'true' }}
needs:
- get_requirements
@@ -729,6 +739,7 @@ jobs:
build_type: 'main'
metamask_environment: 'e2e'
keystore_target: 'qa'
+ runner_provider: ${{ inputs.runner_provider }}
secrets: inherit
e2e-smoke-tests-android:
@@ -746,6 +757,7 @@ jobs:
(fromJSON(needs.smart-e2e-selection.outputs.ai_confidence || '0') >= 80 && needs.smart-e2e-selection.outputs.ai_e2e_test_tags) ||
'["ALL"]'
}}
+ runner_provider: ${{ inputs.runner_provider }}
secrets: inherit
build-ios-apps:
@@ -761,11 +773,13 @@ jobs:
id-token: write
needs: [get_requirements, smart-e2e-selection]
uses: ./.github/workflows/build-ios-e2e.yml
+ with:
+ runner_provider: ${{ inputs.runner_provider }}
secrets: inherit
ios-tests-ready:
name: 'iOS Tests Ready'
- runs-on: ubuntu-latest
+ runs-on: ${{ inputs.runner_provider == 'namespace' && 'namespace-profile-metamask-ci-linux' || 'ubuntu-latest' }}
if: ${{ !cancelled() && needs.build-ios-apps.result == 'success' }}
needs: [build-ios-apps]
steps:
@@ -787,6 +801,7 @@ jobs:
(fromJSON(needs.smart-e2e-selection.outputs.ai_confidence || '0') >= 80 && needs.smart-e2e-selection.outputs.ai_e2e_test_tags) ||
'["ALL"]'
}}
+ runner_provider: ${{ inputs.runner_provider }}
secrets: inherit
# Fixture validation — ensures committed E2E fixtures match the live app state schema
@@ -806,11 +821,12 @@ jobs:
total_splits: 1
build_type: 'main'
metamask_environment: 'qa'
+ runner_provider: ${{ inputs.runner_provider }}
secrets: inherit
report-fixture-validation:
name: 'Report Fixture Validation'
- runs-on: ubuntu-latest
+ runs-on: ${{ inputs.runner_provider == 'namespace' && 'namespace-profile-metamask-ci-linux' || 'ubuntu-latest' }}
if: ${{ !cancelled() && needs.validate-e2e-fixtures.result != 'skipped' }}
needs: [validate-e2e-fixtures]
permissions:
@@ -837,7 +853,7 @@ jobs:
sonar-cloud:
name: SonarCloud analysis
- runs-on: ubuntu-latest
+ runs-on: ${{ inputs.runner_provider == 'namespace' && 'namespace-profile-metamask-ci-linux' || 'ubuntu-latest' }}
needs: merge-unit-and-component-view-tests
if: ${{ !cancelled() && github.event_name != 'merge_group' && !github.event.pull_request.head.repo.fork }}
steps:
@@ -882,7 +898,7 @@ jobs:
sonar-cloud-quality-gate-status:
name: SonarCloud quality gate status
- runs-on: ubuntu-latest
+ runs-on: ${{ inputs.runner_provider == 'namespace' && 'namespace-profile-metamask-ci-linux' || 'ubuntu-latest' }}
needs: sonar-cloud
if: ${{ !cancelled() && github.event_name != 'merge_group' && !github.event.pull_request.head.repo.fork }}
steps:
@@ -942,7 +958,7 @@ jobs:
# Run the aggregate gate even when optional dependencies are skipped.
# The composite action decides which skipped jobs are acceptable.
if: ${{ always() && !cancelled() }}
- runs-on: ubuntu-latest
+ runs-on: ${{ inputs.runner_provider == 'namespace' && 'namespace-profile-metamask-ci-linux' || 'ubuntu-latest' }}
needs:
- get_requirements
- check-diff
@@ -975,7 +991,7 @@ jobs:
log-merge-group-failure:
name: Log merge group failure
- runs-on: ubuntu-latest
+ runs-on: ${{ inputs.runner_provider == 'namespace' && 'namespace-profile-metamask-ci-linux' || 'ubuntu-latest' }}
# Only run this job if the merge group event fails, skip on forks
if: ${{ github.event_name == 'merge_group' && failure() }}
needs:
diff --git a/.github/workflows/expo-dev-build.yml b/.github/workflows/expo-dev-build.yml
index c558ff1297e..1c078efa825 100644
--- a/.github/workflows/expo-dev-build.yml
+++ b/.github/workflows/expo-dev-build.yml
@@ -19,6 +19,15 @@ on:
branches:
- main
workflow_dispatch:
+ inputs:
+ runner_provider:
+ description: Runner provider for this manual trial run
+ required: true
+ type: choice
+ options:
+ - current
+ - namespace
+ default: current
permissions:
contents: write
@@ -32,4 +41,5 @@ jobs:
build_name: main-dev-expo
platform: both
skip_version_bump: true
+ runner_provider: ${{ inputs.runner_provider }}
secrets: inherit
diff --git a/.github/workflows/run-e2e-regression-tests-android.yml b/.github/workflows/run-e2e-regression-tests-android.yml
index fb0a2a7b7b9..a39a83f7a68 100644
--- a/.github/workflows/run-e2e-regression-tests-android.yml
+++ b/.github/workflows/run-e2e-regression-tests-android.yml
@@ -7,6 +7,14 @@ on:
description: 'Send Slack notification even when all tests pass'
type: boolean
default: false
+ runner_provider:
+ description: Runner provider for this manual trial run
+ required: true
+ type: choice
+ options:
+ - current
+ - namespace
+ default: current
permissions:
contents: read
@@ -25,6 +33,7 @@ jobs:
build_type: 'main'
metamask_environment: 'e2e'
keystore_target: 'qa'
+ runner_provider: ${{ inputs.runner_provider }}
secrets: inherit
regression-confirmations-android:
@@ -41,6 +50,7 @@ jobs:
test_suite_tag: 'RegressionConfirmations'
split_number: ${{ matrix.split }}
total_splits: 4
+ runner_provider: ${{ inputs.runner_provider }}
secrets: inherit
regression-trade-android:
@@ -57,6 +67,7 @@ jobs:
test_suite_tag: 'RegressionTrade'
split_number: ${{ matrix.split }}
total_splits: 4
+ runner_provider: ${{ inputs.runner_provider }}
secrets: inherit
regression-wallet-platform-android:
@@ -73,6 +84,7 @@ jobs:
test_suite_tag: 'RegressionWalletPlatform'
split_number: ${{ matrix.split }}
total_splits: 4
+ runner_provider: ${{ inputs.runner_provider }}
secrets: inherit
regression-accounts-android:
@@ -89,6 +101,7 @@ jobs:
test_suite_tag: 'RegressionAccounts'
split_number: ${{ matrix.split }}
total_splits: 4
+ runner_provider: ${{ inputs.runner_provider }}
secrets: inherit
regression-network-abstraction-android:
@@ -105,6 +118,7 @@ jobs:
test_suite_tag: 'RegressionNetworkAbstractions'
split_number: ${{ matrix.split }}
total_splits: 4
+ runner_provider: ${{ inputs.runner_provider }}
secrets: inherit
regression-network-expansion-android:
@@ -121,6 +135,7 @@ jobs:
test_suite_tag: 'RegressionNetworkExpansion'
split_number: ${{ matrix.split }}
total_splits: 4
+ runner_provider: ${{ inputs.runner_provider }}
secrets: inherit
regression-assets-android:
@@ -137,6 +152,7 @@ jobs:
test_suite_tag: 'RegressionAssets'
split_number: ${{ matrix.split }}
total_splits: 2
+ runner_provider: ${{ inputs.runner_provider }}
secrets: inherit
regression-ux-android:
@@ -153,11 +169,12 @@ jobs:
test_suite_tag: 'RegressionWalletUX'
split_number: ${{ matrix.split }}
total_splits: 1
+ runner_provider: ${{ inputs.runner_provider }}
secrets: inherit
report-android-regression-tests:
name: Report Android Regression Tests
- runs-on: ubuntu-latest
+ runs-on: ${{ inputs.runner_provider == 'namespace' && 'namespace-profile-metamask-ci-linux' || 'ubuntu-latest' }}
if: always()
needs:
- build-android-apks
diff --git a/.github/workflows/run-e2e-regression-tests-ios.yml b/.github/workflows/run-e2e-regression-tests-ios.yml
index 29343a469b7..295069cc952 100644
--- a/.github/workflows/run-e2e-regression-tests-ios.yml
+++ b/.github/workflows/run-e2e-regression-tests-ios.yml
@@ -9,6 +9,14 @@ on:
description: 'Send Slack notification even when all tests pass'
type: boolean
default: false
+ runner_provider:
+ description: Runner provider for this manual trial run
+ required: true
+ type: choice
+ options:
+ - current
+ - namespace
+ default: current
permissions:
contents: read
@@ -23,6 +31,8 @@ jobs:
contents: read
id-token: write
uses: ./.github/workflows/build-ios-e2e.yml
+ with:
+ runner_provider: ${{ inputs.runner_provider }}
secrets: inherit
regression-confirmations-ios:
@@ -39,6 +49,7 @@ jobs:
test_suite_tag: 'RegressionConfirmations'
split_number: ${{ matrix.split }}
total_splits: 4
+ runner_provider: ${{ inputs.runner_provider }}
secrets: inherit
regression-trade-ios:
@@ -55,6 +66,7 @@ jobs:
test_suite_tag: 'RegressionTrade'
split_number: ${{ matrix.split }}
total_splits: 4
+ runner_provider: ${{ inputs.runner_provider }}
secrets: inherit
regression-wallet-platform-ios:
@@ -71,6 +83,7 @@ jobs:
test_suite_tag: 'RegressionWalletPlatform'
split_number: ${{ matrix.split }}
total_splits: 4
+ runner_provider: ${{ inputs.runner_provider }}
secrets: inherit
regression-accounts-ios:
@@ -87,6 +100,7 @@ jobs:
test_suite_tag: 'RegressionAccounts'
split_number: ${{ matrix.split }}
total_splits: 4
+ runner_provider: ${{ inputs.runner_provider }}
secrets: inherit
regression-network-abstraction-ios:
@@ -103,6 +117,7 @@ jobs:
test_suite_tag: 'RegressionNetworkAbstractions'
split_number: ${{ matrix.split }}
total_splits: 4
+ runner_provider: ${{ inputs.runner_provider }}
secrets: inherit
regression-network-expansion-ios:
@@ -119,6 +134,7 @@ jobs:
test_suite_tag: 'RegressionNetworkExpansion'
split_number: ${{ matrix.split }}
total_splits: 4
+ runner_provider: ${{ inputs.runner_provider }}
secrets: inherit
regression-assets-ios:
@@ -135,6 +151,7 @@ jobs:
test_suite_tag: 'RegressionAssets'
split_number: ${{ matrix.split }}
total_splits: 2
+ runner_provider: ${{ inputs.runner_provider }}
secrets: inherit
regression-ux-ios:
@@ -151,11 +168,12 @@ jobs:
test_suite_tag: 'RegressionWalletUX'
split_number: ${{ matrix.split }}
total_splits: 1
+ runner_provider: ${{ inputs.runner_provider }}
secrets: inherit
report-ios-regression-tests:
name: Report iOS Regression Tests
- runs-on: ubuntu-latest
+ runs-on: ${{ inputs.runner_provider == 'namespace' && 'namespace-profile-metamask-ci-linux' || 'ubuntu-latest' }}
if: always()
needs:
- regression-confirmations-ios
diff --git a/.github/workflows/run-e2e-smoke-tests-android.yml b/.github/workflows/run-e2e-smoke-tests-android.yml
index a8dc3575fbc..f40ccf3d1e9 100644
--- a/.github/workflows/run-e2e-smoke-tests-android.yml
+++ b/.github/workflows/run-e2e-smoke-tests-android.yml
@@ -13,6 +13,11 @@ on:
required: false
type: string
default: ''
+ runner_provider:
+ description: Runner provider forwarded from the caller
+ required: false
+ type: string
+ default: current
permissions:
contents: read
@@ -33,6 +38,7 @@ jobs:
split_number: ${{ matrix.split }}
total_splits: 2
changed_files: ${{ inputs.changed_files }}
+ runner_provider: ${{ inputs.runner_provider }}
secrets: inherit
stake-android-smoke:
@@ -49,6 +55,7 @@ jobs:
split_number: ${{ matrix.split }}
total_splits: 1
changed_files: ${{ inputs.changed_files }}
+ runner_provider: ${{ inputs.runner_provider }}
secrets: inherit
perps-android-smoke:
@@ -65,6 +72,7 @@ jobs:
split_number: ${{ matrix.split }}
total_splits: 1
changed_files: ${{ inputs.changed_files }}
+ runner_provider: ${{ inputs.runner_provider }}
secrets: inherit
wallet-platform-android-smoke:
@@ -81,6 +89,7 @@ jobs:
split_number: ${{ matrix.split }}
total_splits: 3
changed_files: ${{ inputs.changed_files }}
+ runner_provider: ${{ inputs.runner_provider }}
secrets: inherit
identity-android-smoke:
@@ -97,6 +106,7 @@ jobs:
split_number: ${{ matrix.split }}
total_splits: 2
changed_files: ${{ inputs.changed_files }}
+ runner_provider: ${{ inputs.runner_provider }}
secrets: inherit
accounts-android-smoke:
@@ -113,6 +123,7 @@ jobs:
split_number: ${{ matrix.split }}
total_splits: 1
changed_files: ${{ inputs.changed_files }}
+ runner_provider: ${{ inputs.runner_provider }}
secrets: inherit
network-abstraction-android-smoke:
@@ -129,6 +140,7 @@ jobs:
split_number: ${{ matrix.split }}
total_splits: 2
changed_files: ${{ inputs.changed_files }}
+ runner_provider: ${{ inputs.runner_provider }}
secrets: inherit
network-expansion-android-smoke:
@@ -145,6 +157,7 @@ jobs:
split_number: ${{ matrix.split }}
total_splits: 2
changed_files: ${{ inputs.changed_files }}
+ runner_provider: ${{ inputs.runner_provider }}
secrets: inherit
confirmations-android-smoke:
@@ -161,6 +174,7 @@ jobs:
split_number: ${{ matrix.split }}
total_splits: 4
changed_files: ${{ inputs.changed_files }}
+ runner_provider: ${{ inputs.runner_provider }}
secrets: inherit
prediction-market-android-smoke:
@@ -177,6 +191,7 @@ jobs:
split_number: ${{ matrix.split }}
total_splits: 1
changed_files: ${{ inputs.changed_files }}
+ runner_provider: ${{ inputs.runner_provider }}
secrets: inherit
money-android-smoke:
@@ -193,6 +208,7 @@ jobs:
split_number: ${{ matrix.split }}
total_splits: 1
changed_files: ${{ inputs.changed_files }}
+ runner_provider: ${{ inputs.runner_provider }}
secrets: inherit
multichain-api-android-smoke:
@@ -209,6 +225,7 @@ jobs:
split_number: ${{ matrix.split }}
total_splits: 1
changed_files: ${{ inputs.changed_files }}
+ runner_provider: ${{ inputs.runner_provider }}
secrets: inherit
seedless-onboarding-android-smoke:
@@ -225,6 +242,7 @@ jobs:
split_number: ${{ matrix.split }}
total_splits: 1
changed_files: ${{ inputs.changed_files }}
+ runner_provider: ${{ inputs.runner_provider }}
secrets: inherit
browser-android-smoke:
@@ -241,6 +259,7 @@ jobs:
split_number: ${{ matrix.split }}
total_splits: 1
changed_files: ${{ inputs.changed_files }}
+ runner_provider: ${{ inputs.runner_provider }}
secrets: inherit
snaps-android-smoke:
@@ -257,11 +276,12 @@ jobs:
split_number: ${{ matrix.split }}
total_splits: 4
changed_files: ${{ inputs.changed_files }}
+ runner_provider: ${{ inputs.runner_provider }}
secrets: inherit
report-android-smoke-tests:
name: Report Android Smoke Tests
- runs-on: ubuntu-latest
+ runs-on: ${{ inputs.runner_provider == 'namespace' && 'namespace-profile-metamask-ci-linux' || 'ubuntu-latest' }}
if: ${{ !cancelled() && inputs.selected_tags != '[]' && inputs.selected_tags != '["FlaskBuildTests"]' }}
needs:
- swap-android-smoke
diff --git a/.github/workflows/run-e2e-smoke-tests-ios.yml b/.github/workflows/run-e2e-smoke-tests-ios.yml
index 05b82e8bf05..19fb3ee5d7c 100644
--- a/.github/workflows/run-e2e-smoke-tests-ios.yml
+++ b/.github/workflows/run-e2e-smoke-tests-ios.yml
@@ -13,6 +13,11 @@ on:
required: false
type: string
default: ''
+ runner_provider:
+ description: Runner provider forwarded from the caller
+ required: false
+ type: string
+ default: current
permissions:
contents: read
@@ -35,6 +40,7 @@ jobs:
changed_files: ${{ inputs.changed_files }}
build_type: 'main'
metamask_environment: 'qa'
+ runner_provider: ${{ inputs.runner_provider }}
secrets: inherit
swap-ios-smoke:
@@ -53,6 +59,7 @@ jobs:
changed_files: ${{ inputs.changed_files }}
build_type: 'main'
metamask_environment: 'qa'
+ runner_provider: ${{ inputs.runner_provider }}
secrets: inherit
stake-ios-smoke:
@@ -71,6 +78,7 @@ jobs:
changed_files: ${{ inputs.changed_files }}
build_type: 'main'
metamask_environment: 'qa'
+ runner_provider: ${{ inputs.runner_provider }}
secrets: inherit
perps-ios-smoke:
@@ -89,6 +97,7 @@ jobs:
changed_files: ${{ inputs.changed_files }}
build_type: 'main'
metamask_environment: 'qa'
+ runner_provider: ${{ inputs.runner_provider }}
secrets: inherit
wallet-platform-ios-smoke:
@@ -107,6 +116,7 @@ jobs:
changed_files: ${{ inputs.changed_files }}
build_type: 'main'
metamask_environment: 'qa'
+ runner_provider: ${{ inputs.runner_provider }}
secrets: inherit
identity-ios-smoke:
@@ -125,6 +135,7 @@ jobs:
changed_files: ${{ inputs.changed_files }}
build_type: 'main'
metamask_environment: 'qa'
+ runner_provider: ${{ inputs.runner_provider }}
secrets: inherit
accounts-ios-smoke:
@@ -143,6 +154,7 @@ jobs:
changed_files: ${{ inputs.changed_files }}
build_type: 'main'
metamask_environment: 'qa'
+ runner_provider: ${{ inputs.runner_provider }}
secrets: inherit
network-abstraction-ios-smoke:
@@ -161,6 +173,7 @@ jobs:
changed_files: ${{ inputs.changed_files }}
build_type: 'main'
metamask_environment: 'qa'
+ runner_provider: ${{ inputs.runner_provider }}
secrets: inherit
network-expansion-ios-smoke:
@@ -179,6 +192,7 @@ jobs:
changed_files: ${{ inputs.changed_files }}
build_type: 'main'
metamask_environment: 'qa'
+ runner_provider: ${{ inputs.runner_provider }}
secrets: inherit
prediction-market-ios-smoke:
@@ -197,6 +211,7 @@ jobs:
changed_files: ${{ inputs.changed_files }}
build_type: 'main'
metamask_environment: 'qa'
+ runner_provider: ${{ inputs.runner_provider }}
secrets: inherit
money-ios-smoke:
@@ -215,6 +230,7 @@ jobs:
changed_files: ${{ inputs.changed_files }}
build_type: 'main'
metamask_environment: 'qa'
+ runner_provider: ${{ inputs.runner_provider }}
secrets: inherit
multichain-api-ios-smoke:
@@ -233,6 +249,7 @@ jobs:
changed_files: ${{ inputs.changed_files }}
build_type: 'main'
metamask_environment: 'qa'
+ runner_provider: ${{ inputs.runner_provider }}
secrets: inherit
seedless-onboarding-ios-smoke:
@@ -251,6 +268,7 @@ jobs:
changed_files: ${{ inputs.changed_files }}
build_type: 'main'
metamask_environment: 'qa'
+ runner_provider: ${{ inputs.runner_provider }}
secrets: inherit
browser-ios-smoke:
@@ -269,6 +287,7 @@ jobs:
changed_files: ${{ inputs.changed_files }}
build_type: 'main'
metamask_environment: 'qa'
+ runner_provider: ${{ inputs.runner_provider }}
secrets: inherit
snaps-ios-smoke:
@@ -287,11 +306,12 @@ jobs:
changed_files: ${{ inputs.changed_files }}
build_type: 'main'
metamask_environment: 'qa'
+ runner_provider: ${{ inputs.runner_provider }}
secrets: inherit
report-ios-smoke-tests:
name: Report iOS Smoke Tests
- runs-on: ubuntu-latest
+ runs-on: ${{ inputs.runner_provider == 'namespace' && 'namespace-profile-metamask-ci-linux' || 'ubuntu-latest' }}
if: ${{ !cancelled() && inputs.selected_tags != '[]' && inputs.selected_tags != '["FlaskBuildTests"]' }}
needs:
- confirmations-ios-smoke
diff --git a/.github/workflows/run-e2e-workflow.yml b/.github/workflows/run-e2e-workflow.yml
index 1cc5a894949..efea199b9dd 100644
--- a/.github/workflows/run-e2e-workflow.yml
+++ b/.github/workflows/run-e2e-workflow.yml
@@ -53,11 +53,16 @@ on:
required: false
type: string
default: 'main-'
+ runner_provider:
+ description: Runner provider forwarded from the caller
+ required: false
+ type: string
+ default: current
jobs:
test-e2e-mobile:
name: ${{ inputs.test-suite-name }}
- runs-on: ${{ inputs.platform == 'ios' && 'ghcr.io/cirruslabs/macos-runner:tahoe' || 'ghcr.io/cirruslabs/ubuntu-runner-amd64:24.04-lg' }}
+ runs-on: ${{ inputs.runner_provider == 'namespace' && (inputs.platform == 'ios' && 'namespace-profile-metamask-ios-e2e' || 'namespace-profile-metamask-android-build') || (inputs.platform == 'ios' && 'ghcr.io/cirruslabs/macos-runner:tahoe' || 'ghcr.io/cirruslabs/ubuntu-runner-amd64:24.04-lg') }}
outputs:
apk-target-path: ${{ steps.determine-target-paths.outputs.apk-target-path }}
test-apk-target-path: ${{ steps.determine-target-paths.outputs.test-apk-target-path }}
diff --git a/.github/workflows/setup-node-modules.yml b/.github/workflows/setup-node-modules.yml
index f87f6145ee4..d63574630f1 100644
--- a/.github/workflows/setup-node-modules.yml
+++ b/.github/workflows/setup-node-modules.yml
@@ -58,6 +58,11 @@ on:
required: false
type: number
default: 1
+ runner_provider:
+ description: Runner provider forwarded from the caller
+ required: false
+ type: string
+ default: current
outputs:
artifact-name:
description: 'The actual artifact name used'
@@ -71,7 +76,7 @@ jobs:
setup:
name: Setup Node Modules ${{ inputs.platform && format('({0})', inputs.platform) || '' }}
# Platform-specific runner to match consumer (build needs same OS for native deps/symlinks)
- runs-on: ${{ inputs.platform == 'ios' && 'ghcr.io/cirruslabs/macos-runner:tahoe-xl' || (inputs.platform == 'android' && 'ghcr.io/cirruslabs/ubuntu-runner-amd64:24.04-lg' || 'ubuntu-latest') }}
+ runs-on: ${{ inputs.runner_provider == 'namespace' && (inputs.platform == 'ios' && 'namespace-profile-metamask-ios-build' || (inputs.platform == 'android' && 'namespace-profile-metamask-android-build' || 'namespace-profile-metamask-ci-linux')) || (inputs.platform == 'ios' && 'ghcr.io/cirruslabs/macos-runner:tahoe-xl' || (inputs.platform == 'android' && 'ghcr.io/cirruslabs/ubuntu-runner-amd64:24.04-lg' || 'ubuntu-latest')) }}
permissions:
contents: read
id-token: write
From 99e45579362e8113e6f816f954f720bdea99d9b9 Mon Sep 17 00:00:00 2001
From: =?UTF-8?q?Ant=C3=B3nio=20Regadas?=
Date: Thu, 7 May 2026 12:17:58 +0100
Subject: [PATCH 12/13] chore: add Whats Happening segment events analytics
(#29803)
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
## **Description**
Wires 5 events for Whats Happening analytics:
Card Scrolled to View, emitted via `useViewportTracking` in
WhatsHappeningCard
Opened — emitted in `WhatsHappeningSection` on card press and View All
Viewed — emitted in `WhatsHappeningDetailView` on initial mount and on
each carousel scroll
Interaction — emitted in `TokenRow`
Closed — emitted in `WhatsHappeningDetailView` on back press
## **Changelog**
CHANGELOG entry: null
## **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]
> **Medium Risk**
> Adds new analytics instrumentation across the Whats Happening homepage
section and detail flow, including new component props and
scroll/visibility hooks that could subtly affect rendering or event
duplication if indices/items drift.
>
> **Overview**
> Adds a full set of **MetaMetrics Whats Happening** events (new
constants in `MetaMetrics.events.ts`) and wires them through the
homepage section and detail experience.
>
> Homepage now tracks `WHATS_HAPPENING_OPENED` with an `entry_point` for
both card taps and *View more*, and cards emit
`WHATS_HAPPENING_CARD_SCROLLED_TO_VIEW` via `useViewportTracking`
(introducing a required `cardIndex` prop).
>
> Detail view now tracks `WHATS_HAPPENING_VIEWED` once on initial mount
and again when the carousel settles on a new index, and tracks
`WHATS_HAPPENING_CLOSED` on back. Token/perps CTAs and source link
presses now emit `WHATS_HAPPENING_INTERACTION` with standardized
properties via the new shared helper `getWhatsHappeningEventProps` (plus
`interaction_type` and asset/source fields).
>
> Reviewed by [Cursor Bugbot](https://cursor.com/bugbot) for commit
9d3ed1a2a955db9a566b50ebf1c27d1130294e07. Bugbot is set up for automated
code reviews on this repo. Configure
[here](https://www.cursor.com/dashboard/bugbot).
---
.../WhatsHappeningSection.test.tsx | 61 +++++
.../WhatsHappening/WhatsHappeningSection.tsx | 27 ++-
.../components/WhatsHappeningCard.test.tsx | 80 +++++--
.../components/WhatsHappeningCard.tsx | 209 ++++++++++--------
.../Sections/WhatsHappening/constants.ts | 11 +
.../WhatsHappening/eventProperties.ts | 31 +++
.../WhatsHappeningDetailView.test.tsx | 147 +++++++++++-
.../WhatsHappeningDetailView.tsx | 60 ++++-
.../components/PerpsRow.test.tsx | 83 ++++++-
.../components/PerpsRow.tsx | 37 +++-
.../components/TokenRow.test.tsx | 106 ++++++++-
.../components/TokenRow.tsx | 54 ++++-
.../WhatsHappeningExpandedCard.test.tsx | 24 ++
.../components/WhatsHappeningExpandedCard.tsx | 18 +-
.../WhatsHappeningSourcesBottomSheet.test.tsx | 95 +++++++-
.../WhatsHappeningSourcesBottomSheet.tsx | 32 ++-
app/core/Analytics/MetaMetrics.events.ts | 17 ++
17 files changed, 950 insertions(+), 142 deletions(-)
create mode 100644 app/components/Views/Homepage/Sections/WhatsHappening/eventProperties.ts
diff --git a/app/components/Views/Homepage/Sections/WhatsHappening/WhatsHappeningSection.test.tsx b/app/components/Views/Homepage/Sections/WhatsHappening/WhatsHappeningSection.test.tsx
index 3b75c3cb825..06c51c82086 100644
--- a/app/components/Views/Homepage/Sections/WhatsHappening/WhatsHappeningSection.test.tsx
+++ b/app/components/Views/Homepage/Sections/WhatsHappening/WhatsHappeningSection.test.tsx
@@ -3,8 +3,16 @@ import { screen, fireEvent } from '@testing-library/react-native';
import renderWithProvider from '../../../../../util/test/renderWithProvider';
import WhatsHappeningSection from './WhatsHappeningSection';
import Routes from '../../../../../constants/navigation/Routes';
+import { MetaMetricsEvents } from '../../../../../core/Analytics/MetaMetrics.events';
const mockNavigate = jest.fn();
+const mockTrackEvent = jest.fn();
+const mockCreateEventBuilder = jest.fn((eventName: string) => ({
+ addProperties: jest.fn((properties: Record) => ({
+ build: jest.fn(() => ({ category: eventName, properties })),
+ })),
+ build: jest.fn(() => ({ category: eventName })),
+}));
jest.mock('@react-navigation/native', () => {
const actual = jest.requireActual('@react-navigation/native');
@@ -30,6 +38,13 @@ jest.mock('./hooks', () => ({
})),
}));
+jest.mock('../../../../hooks/useAnalytics/useAnalytics', () => ({
+ useAnalytics: () => ({
+ trackEvent: mockTrackEvent,
+ createEventBuilder: mockCreateEventBuilder,
+ }),
+}));
+
const mockUseWhatsHappening = jest.requireMock('./hooks').useWhatsHappening;
const mockSelectWhatsHappeningEnabled = jest.requireMock(
'../../../../../selectors/featureFlagController/whatsHappening',
@@ -175,4 +190,50 @@ describe('WhatsHappeningSection', () => {
initialIndex: 1,
});
});
+
+ it('tracks Whats Happening Opened with entry_point=card when a card is pressed', () => {
+ mockUseWhatsHappening.mockReturnValue({
+ items: [mockItem],
+ isLoading: false,
+ error: null,
+ refresh: jest.fn(),
+ });
+ renderWithProvider();
+ fireEvent.press(screen.getByText(mockItem.title));
+ expect(mockCreateEventBuilder).toHaveBeenCalledWith(
+ MetaMetricsEvents.WHATS_HAPPENING_OPENED,
+ );
+ expect(mockTrackEvent).toHaveBeenCalledWith(
+ expect.objectContaining({
+ category: MetaMetricsEvents.WHATS_HAPPENING_OPENED,
+ properties: expect.objectContaining({
+ entry_point: 'card',
+ event_id: mockItem.id,
+ card_index: 0,
+ category: 'macro',
+ impact: 'positive',
+ asset_symbols: [],
+ }),
+ }),
+ );
+ });
+
+ it('tracks Whats Happening Opened with entry_point=view_all when View More is pressed', () => {
+ mockUseWhatsHappening.mockReturnValue({
+ items: [mockItem],
+ isLoading: false,
+ error: null,
+ refresh: jest.fn(),
+ });
+ renderWithProvider();
+ fireEvent.press(screen.getByText(/view more/i));
+ expect(mockTrackEvent).toHaveBeenCalledWith(
+ expect.objectContaining({
+ category: MetaMetricsEvents.WHATS_HAPPENING_OPENED,
+ properties: expect.objectContaining({
+ entry_point: 'view_all',
+ }),
+ }),
+ );
+ });
});
diff --git a/app/components/Views/Homepage/Sections/WhatsHappening/WhatsHappeningSection.tsx b/app/components/Views/Homepage/Sections/WhatsHappening/WhatsHappeningSection.tsx
index 1fa9d6fdeca..1a2507d663e 100644
--- a/app/components/Views/Homepage/Sections/WhatsHappening/WhatsHappeningSection.tsx
+++ b/app/components/Views/Homepage/Sections/WhatsHappening/WhatsHappeningSection.tsx
@@ -16,7 +16,7 @@ import { SectionRefreshHandle } from '../../types';
import { selectWhatsHappeningEnabled } from '../../../../../selectors/featureFlagController/whatsHappening';
import { strings } from '../../../../../../locales/i18n';
import Routes from '../../../../../constants/navigation/Routes';
-import { MAX_ITEMS_DISPLAYED } from './constants';
+import { MAX_ITEMS_DISPLAYED, WhatsHappeningEntryPoint } from './constants';
import { useWhatsHappening } from './hooks';
import { WhatsHappeningCard, WhatsHappeningCardSkeleton } from './components';
import useHomeViewedEvent, {
@@ -24,6 +24,9 @@ import useHomeViewedEvent, {
} from '../../hooks/useHomeViewedEvent';
import { useSectionPerformance } from '../../hooks/useSectionPerformance';
import { WalletViewSelectorsIDs } from '../../../Wallet/WalletView.testIds';
+import { MetaMetricsEvents } from '../../../../../core/Analytics';
+import { useAnalytics } from '../../../../hooks/useAnalytics/useAnalytics';
+import { getWhatsHappeningEventProps } from './eventProperties';
const CARD_WIDTH = 280;
const GAP = 12;
@@ -56,6 +59,7 @@ const WhatsHappeningSection = forwardRef<
const navigation = useNavigation();
const isEnabled = useSelector(selectWhatsHappeningEnabled);
const title = strings('homepage.sections.whats_happening');
+ const { trackEvent, createEventBuilder } = useAnalytics();
const { items, isLoading, error, refresh } =
useWhatsHappening(MAX_ITEMS_DISPLAYED);
@@ -98,14 +102,30 @@ const WhatsHappeningSection = forwardRef<
);
const handleViewAll = useCallback(() => {
+ trackEvent(
+ createEventBuilder(MetaMetricsEvents.WHATS_HAPPENING_OPENED)
+ .addProperties({ entry_point: WhatsHappeningEntryPoint.ViewAll })
+ .build(),
+ );
navigateToDetail(0);
- }, [navigateToDetail]);
+ }, [navigateToDetail, trackEvent, createEventBuilder]);
const handleCardPress = useCallback(
(index: number) => {
+ const item = items[index];
+ if (item) {
+ trackEvent(
+ createEventBuilder(MetaMetricsEvents.WHATS_HAPPENING_OPENED)
+ .addProperties({
+ ...getWhatsHappeningEventProps(item, index),
+ entry_point: WhatsHappeningEntryPoint.Card,
+ })
+ .build(),
+ );
+ }
navigateToDetail(index);
},
- [navigateToDetail],
+ [items, navigateToDetail, trackEvent, createEventBuilder],
);
if (!isEnabled) {
@@ -161,6 +181,7 @@ const WhatsHappeningSection = forwardRef<
handleCardPress(index)}
/>
))}
diff --git a/app/components/Views/Homepage/Sections/WhatsHappening/components/WhatsHappeningCard.test.tsx b/app/components/Views/Homepage/Sections/WhatsHappening/components/WhatsHappeningCard.test.tsx
index b2decb5c8bf..ab46f9cd626 100644
--- a/app/components/Views/Homepage/Sections/WhatsHappening/components/WhatsHappeningCard.test.tsx
+++ b/app/components/Views/Homepage/Sections/WhatsHappening/components/WhatsHappeningCard.test.tsx
@@ -3,6 +3,30 @@ import { screen, fireEvent } from '@testing-library/react-native';
import renderWithProvider from '../../../../../../util/test/renderWithProvider';
import WhatsHappeningCard from './WhatsHappeningCard';
import type { WhatsHappeningItem } from '../types';
+import { MetaMetricsEvents } from '../../../../../../core/Analytics/MetaMetrics.events';
+
+const mockTrackEvent = jest.fn();
+const mockCreateEventBuilder = jest.fn((eventName: string) => ({
+ addProperties: jest.fn((properties: Record) => ({
+ build: jest.fn(() => ({ category: eventName, properties })),
+ })),
+ build: jest.fn(() => ({ category: eventName })),
+}));
+
+let capturedOnVisible: (() => void) | null = null;
+jest.mock('../../../../../UI/MarketInsights/hooks/useViewportTracking', () => ({
+ useViewportTracking: (onVisible: () => void) => {
+ capturedOnVisible = onVisible;
+ return { ref: { current: null }, onLayout: jest.fn() };
+ },
+}));
+
+jest.mock('../../../../../hooks/useAnalytics/useAnalytics', () => ({
+ useAnalytics: () => ({
+ trackEvent: mockTrackEvent,
+ createEventBuilder: mockCreateEventBuilder,
+ }),
+}));
const mockRelatedAsset = {
sourceAssetId: 'btc-mainnet',
@@ -23,63 +47,68 @@ const baseItem: WhatsHappeningItem = {
};
describe('WhatsHappeningCard', () => {
+ beforeEach(() => {
+ jest.clearAllMocks();
+ capturedOnVisible = null;
+ });
+
it('renders title and description', () => {
- renderWithProvider();
+ renderWithProvider();
expect(screen.getByText(baseItem.title)).toBeOnTheScreen();
expect(screen.getByText(baseItem.description)).toBeOnTheScreen();
});
it('renders category badge when category is provided', () => {
- renderWithProvider();
+ renderWithProvider();
expect(screen.getByText('Macro')).toBeOnTheScreen();
});
it('does not render category badge when category is absent', () => {
const item = { ...baseItem, category: undefined };
- renderWithProvider();
+ renderWithProvider();
expect(screen.queryByText('Macro')).toBeNull();
});
it('renders Bullish impact badge for positive impact', () => {
const item = { ...baseItem, impact: 'positive' as const };
- renderWithProvider();
+ renderWithProvider();
expect(screen.getByText('Bullish')).toBeOnTheScreen();
});
it('renders Bearish impact badge for negative impact', () => {
const item = { ...baseItem, impact: 'negative' as const };
- renderWithProvider();
+ renderWithProvider();
expect(screen.getByText('Bearish')).toBeOnTheScreen();
});
it('renders Neutral impact badge for neutral impact', () => {
const item = { ...baseItem, impact: 'neutral' as const };
- renderWithProvider();
+ renderWithProvider();
expect(screen.getByText('Neutral')).toBeOnTheScreen();
});
it('does not render impact badge when impact is absent', () => {
const item = { ...baseItem, impact: undefined };
- renderWithProvider();
+ renderWithProvider();
expect(screen.queryByText('Bullish')).toBeNull();
expect(screen.queryByText('Bearish')).toBeNull();
expect(screen.queryByText('Neutral')).toBeNull();
});
it('renders impact badge alongside category badge', () => {
- renderWithProvider();
+ renderWithProvider();
expect(screen.getByText('Bullish')).toBeOnTheScreen();
expect(screen.getByText('Macro')).toBeOnTheScreen();
});
it('renders related asset symbol pills', () => {
- renderWithProvider();
+ renderWithProvider();
expect(screen.getByText('BTC')).toBeOnTheScreen();
});
it('does not render asset pills when relatedAssets is empty', () => {
const item = { ...baseItem, relatedAssets: [] };
- renderWithProvider();
+ renderWithProvider();
expect(screen.queryByText('BTC')).toBeNull();
});
@@ -91,26 +120,26 @@ describe('WhatsHappeningCard', () => {
caip19: ['eip155:1/slip44:60'],
};
const item = { ...baseItem, relatedAssets: [mockRelatedAsset, ethAsset] };
- renderWithProvider();
+ renderWithProvider();
expect(screen.getByText('BTC')).toBeOnTheScreen();
expect(screen.getByText('ETH')).toBeOnTheScreen();
});
it('renders formatted date when date is valid', () => {
- renderWithProvider();
+ renderWithProvider();
expect(screen.getByText('Mar 15, 2026')).toBeOnTheScreen();
});
it('does not render date when date string is invalid', () => {
const item = { ...baseItem, date: 'not-a-date' };
- renderWithProvider();
+ renderWithProvider();
expect(screen.queryByText('not-a-date')).toBeNull();
});
it('calls onPress with the item when tapped', () => {
const onPress = jest.fn();
renderWithProvider(
- ,
+ ,
);
fireEvent.press(screen.getByText(baseItem.title));
expect(onPress).toHaveBeenCalledTimes(1);
@@ -118,9 +147,30 @@ describe('WhatsHappeningCard', () => {
});
it('does not throw when onPress is not provided', () => {
- renderWithProvider();
+ renderWithProvider();
expect(() =>
fireEvent.press(screen.getByText(baseItem.title)),
).not.toThrow();
});
+
+ it('tracks Whats Happening Card Scrolled to View when card becomes visible', () => {
+ renderWithProvider();
+ expect(capturedOnVisible).not.toBeNull();
+ capturedOnVisible?.();
+ expect(mockCreateEventBuilder).toHaveBeenCalledWith(
+ MetaMetricsEvents.WHATS_HAPPENING_CARD_SCROLLED_TO_VIEW,
+ );
+ expect(mockTrackEvent).toHaveBeenCalledWith(
+ expect.objectContaining({
+ category: MetaMetricsEvents.WHATS_HAPPENING_CARD_SCROLLED_TO_VIEW,
+ properties: expect.objectContaining({
+ event_id: 'trend-0',
+ card_index: 2,
+ category: 'macro',
+ impact: 'positive',
+ asset_symbols: ['BTC'],
+ }),
+ }),
+ );
+ });
});
diff --git a/app/components/Views/Homepage/Sections/WhatsHappening/components/WhatsHappeningCard.tsx b/app/components/Views/Homepage/Sections/WhatsHappening/components/WhatsHappeningCard.tsx
index f8d8f42975d..e1c7065fa69 100644
--- a/app/components/Views/Homepage/Sections/WhatsHappening/components/WhatsHappeningCard.tsx
+++ b/app/components/Views/Homepage/Sections/WhatsHappening/components/WhatsHappeningCard.tsx
@@ -1,5 +1,5 @@
-import React, { useMemo } from 'react';
-import { TouchableOpacity } from 'react-native';
+import React, { useCallback, useMemo } from 'react';
+import { TouchableOpacity, View } from 'react-native';
import { useTailwind } from '@metamask/design-system-twrnc-preset';
import {
Box,
@@ -18,118 +18,143 @@ import {
getImpactBackgroundClass,
getImpactTextColor,
} from '../util/impact';
+import { MetaMetricsEvents } from '../../../../../../core/Analytics';
+import { useAnalytics } from '../../../../../hooks/useAnalytics/useAnalytics';
+import { useViewportTracking } from '../../../../../UI/MarketInsights/hooks/useViewportTracking';
+import { getWhatsHappeningEventProps } from '../eventProperties';
interface WhatsHappeningCardProps {
item: WhatsHappeningItem;
+ cardIndex: number;
onPress?: (item: WhatsHappeningItem) => void;
}
const WhatsHappeningCard: React.FC = ({
item,
+ cardIndex,
onPress,
}) => {
const tw = useTailwind();
const formattedDate = useMemo(() => formatShortDate(item.date), [item.date]);
+ const { trackEvent, createEventBuilder } = useAnalytics();
const handlePress = () => onPress?.(item);
+ const handleVisible = useCallback(() => {
+ trackEvent(
+ createEventBuilder(
+ MetaMetricsEvents.WHATS_HAPPENING_CARD_SCROLLED_TO_VIEW,
+ )
+ .addProperties(getWhatsHappeningEventProps(item, cardIndex))
+ .build(),
+ );
+ }, [trackEvent, createEventBuilder, item, cardIndex]);
+
+ const { ref: cardRef, onLayout: onVisibilityLayout } =
+ useViewportTracking(handleVisible);
+
return (
-
-
- {/* Impact + Category badges */}
- {(item.impact || item.category) && (
-
- {item.impact && (
-
-
- {getImpactLabel(item.impact)}
-
-
- )}
- {item.category && (
-
-
- {strings(
- `homepage.sections.whats_happening_categories.${item.category}`,
- )}
-
-
- )}
-
+
+
+
+ {/* Impact + Category badges */}
+ {(item.impact || item.category) && (
+
+ {item.impact && (
+
+
+ {getImpactLabel(item.impact)}
+
+
+ )}
+ {item.category && (
+
+
+ {strings(
+ `homepage.sections.whats_happening_categories.${item.category}`,
+ )}
+
+
+ )}
+
+ )}
- {/* Title */}
-
- {item.title}
-
-
- {/* Description */}
-
- {item.description}
-
-
+ {/* Title */}
+
+ {item.title}
+
- {/* Footer: asset pills + date */}
-
- {item.relatedAssets.length > 0 && (
-
- {item.relatedAssets.map((asset) => (
-
-
+
+
+ {/* Footer: asset pills + date */}
+
+ {item.relatedAssets.length > 0 && (
+
+ {item.relatedAssets.map((asset) => (
+
- {asset.symbol}
-
-
- ))}
-
- )}
+
+ {asset.symbol}
+
+
+ ))}
+
+ )}
- {formattedDate && (
-
- {formattedDate}
-
- )}
-
-
+ {formattedDate && (
+
+ {formattedDate}
+
+ )}
+
+
+
);
};
diff --git a/app/components/Views/Homepage/Sections/WhatsHappening/constants.ts b/app/components/Views/Homepage/Sections/WhatsHappening/constants.ts
index 62ac194b310..05d74115589 100644
--- a/app/components/Views/Homepage/Sections/WhatsHappening/constants.ts
+++ b/app/components/Views/Homepage/Sections/WhatsHappening/constants.ts
@@ -1 +1,12 @@
export const MAX_ITEMS_DISPLAYED = 5;
+
+export const WhatsHappeningEntryPoint = {
+ Card: 'card',
+ ViewAll: 'view_all',
+} as const;
+
+export const WhatsHappeningInteractionType = {
+ SourceClick: 'source_click',
+ BuyPressed: 'buy_pressed',
+ TradePressed: 'trade_pressed',
+} as const;
diff --git a/app/components/Views/Homepage/Sections/WhatsHappening/eventProperties.ts b/app/components/Views/Homepage/Sections/WhatsHappening/eventProperties.ts
new file mode 100644
index 00000000000..ae75f319382
--- /dev/null
+++ b/app/components/Views/Homepage/Sections/WhatsHappening/eventProperties.ts
@@ -0,0 +1,31 @@
+import type { WhatsHappeningItem } from './types';
+
+/**
+ * Shared properties bag for Whats Happening analytics events.
+ * Used by CARD_SCROLLED_TO_VIEW, OPENED, VIEWED, INTERACTION, and CLOSED.
+ *
+ * Optional fields (`category`, `impact`) are stripped when undefined so
+ * the resulting payload only includes keys that have a real value.
+ *
+ * The shape is widened with `Record` so the result can be
+ * passed directly to `AnalyticsEventBuilder.addProperties`, which expects
+ * an index-signature-compatible bag.
+ */
+export type WhatsHappeningEventProps = {
+ event_id: string;
+ card_index: number;
+ category?: WhatsHappeningItem['category'];
+ impact?: WhatsHappeningItem['impact'];
+ asset_symbols: string[];
+} & Record;
+
+export const getWhatsHappeningEventProps = (
+ item: WhatsHappeningItem,
+ cardIndex: number,
+): WhatsHappeningEventProps => ({
+ event_id: item.id,
+ card_index: cardIndex,
+ ...(item.category ? { category: item.category } : {}),
+ ...(item.impact ? { impact: item.impact } : {}),
+ asset_symbols: item.relatedAssets.map((asset) => asset.symbol),
+});
diff --git a/app/components/Views/WhatsHappeningDetailView/WhatsHappeningDetailView.test.tsx b/app/components/Views/WhatsHappeningDetailView/WhatsHappeningDetailView.test.tsx
index 8da2db4bd1f..ce2c2c3f5b7 100644
--- a/app/components/Views/WhatsHappeningDetailView/WhatsHappeningDetailView.test.tsx
+++ b/app/components/Views/WhatsHappeningDetailView/WhatsHappeningDetailView.test.tsx
@@ -1,10 +1,23 @@
import React from 'react';
import { screen, fireEvent } from '@testing-library/react-native';
import renderWithProvider from '../../../util/test/renderWithProvider';
-import WhatsHappeningDetailView from './WhatsHappeningDetailView';
+import WhatsHappeningDetailView, {
+ CARD_WIDTH,
+} from './WhatsHappeningDetailView';
+import { MetaMetricsEvents } from '../../../core/Analytics/MetaMetrics.events';
+
+const GAP = 12;
+const SNAP_INTERVAL_FOR_TEST = CARD_WIDTH + GAP;
const mockGoBack = jest.fn();
const mockRefresh = jest.fn();
+const mockTrackEvent = jest.fn();
+const mockCreateEventBuilder = jest.fn((eventName: string) => ({
+ addProperties: jest.fn((properties: Record) => ({
+ build: jest.fn(() => ({ category: eventName, properties })),
+ })),
+ build: jest.fn(() => ({ category: eventName })),
+}));
jest.mock('@react-navigation/native', () => {
const actual = jest.requireActual('@react-navigation/native');
@@ -15,6 +28,13 @@ jest.mock('@react-navigation/native', () => {
};
});
+jest.mock('../../hooks/useAnalytics/useAnalytics', () => ({
+ useAnalytics: () => ({
+ trackEvent: mockTrackEvent,
+ createEventBuilder: mockCreateEventBuilder,
+ }),
+}));
+
jest.mock('../Homepage/Sections/WhatsHappening/hooks', () => ({
useWhatsHappening: jest.fn(() => ({
items: [],
@@ -135,8 +155,133 @@ describe('WhatsHappeningDetailView', () => {
});
it('calls navigation.goBack when back button is pressed', () => {
+ mockUseWhatsHappening.mockReturnValue({
+ items: [mockItem],
+ isLoading: false,
+ error: null,
+ refresh: mockRefresh,
+ });
renderWithProvider();
fireEvent.press(screen.getByTestId('whats-happening-detail-back-button'));
expect(mockGoBack).toHaveBeenCalledTimes(1);
});
+
+ it('tracks Whats Happening Viewed once for the initial card on mount', () => {
+ mockUseWhatsHappening.mockReturnValue({
+ items: [mockItem],
+ isLoading: false,
+ error: null,
+ refresh: mockRefresh,
+ });
+ renderWithProvider();
+ expect(mockCreateEventBuilder).toHaveBeenCalledWith(
+ MetaMetricsEvents.WHATS_HAPPENING_VIEWED,
+ );
+ expect(mockTrackEvent).toHaveBeenCalledWith(
+ expect.objectContaining({
+ category: MetaMetricsEvents.WHATS_HAPPENING_VIEWED,
+ properties: expect.objectContaining({
+ event_id: mockItem.id,
+ card_index: 0,
+ category: 'macro',
+ impact: 'positive',
+ asset_symbols: [],
+ }),
+ }),
+ );
+ });
+
+ it('does not fire Viewed more than once for the initial card across re-renders', () => {
+ mockUseWhatsHappening.mockReturnValue({
+ items: [mockItem],
+ isLoading: false,
+ error: null,
+ refresh: mockRefresh,
+ });
+ const { rerender } = renderWithProvider();
+ rerender();
+ const viewedCalls = mockCreateEventBuilder.mock.calls.filter(
+ ([name]) =>
+ name ===
+ (MetaMetricsEvents.WHATS_HAPPENING_VIEWED as unknown as string),
+ );
+ expect(viewedCalls).toHaveLength(1);
+ });
+
+ it('tracks Whats Happening Viewed when scrolling to a new card', () => {
+ const secondItem = {
+ ...mockItem,
+ id: 'trend-1',
+ title: 'Second trend',
+ category: 'social' as const,
+ impact: 'negative' as const,
+ };
+ mockUseWhatsHappening.mockReturnValue({
+ items: [mockItem, secondItem],
+ isLoading: false,
+ error: null,
+ refresh: mockRefresh,
+ });
+ renderWithProvider();
+ mockTrackEvent.mockClear();
+ mockCreateEventBuilder.mockClear();
+ const carousel = screen.getByTestId('whats-happening-detail-carousel');
+ fireEvent(carousel, 'onMomentumScrollEnd', {
+ nativeEvent: { contentOffset: { x: SNAP_INTERVAL_FOR_TEST } },
+ });
+ expect(mockCreateEventBuilder).toHaveBeenCalledWith(
+ MetaMetricsEvents.WHATS_HAPPENING_VIEWED,
+ );
+ expect(mockTrackEvent).toHaveBeenCalledWith(
+ expect.objectContaining({
+ category: MetaMetricsEvents.WHATS_HAPPENING_VIEWED,
+ properties: expect.objectContaining({
+ event_id: 'trend-1',
+ card_index: 1,
+ category: 'social',
+ impact: 'negative',
+ }),
+ }),
+ );
+ });
+
+ it('does not track Viewed when scroll resolves to same index', () => {
+ mockUseWhatsHappening.mockReturnValue({
+ items: [mockItem],
+ isLoading: false,
+ error: null,
+ refresh: mockRefresh,
+ });
+ renderWithProvider();
+ mockTrackEvent.mockClear();
+ mockCreateEventBuilder.mockClear();
+ const carousel = screen.getByTestId('whats-happening-detail-carousel');
+ fireEvent(carousel, 'onMomentumScrollEnd', {
+ nativeEvent: { contentOffset: { x: 0 } },
+ });
+ expect(mockCreateEventBuilder).not.toHaveBeenCalled();
+ });
+
+ it('tracks Whats Happening Closed with the visible card when back is pressed', () => {
+ mockUseWhatsHappening.mockReturnValue({
+ items: [mockItem],
+ isLoading: false,
+ error: null,
+ refresh: mockRefresh,
+ });
+ renderWithProvider();
+ fireEvent.press(screen.getByTestId('whats-happening-detail-back-button'));
+ expect(mockCreateEventBuilder).toHaveBeenCalledWith(
+ MetaMetricsEvents.WHATS_HAPPENING_CLOSED,
+ );
+ expect(mockTrackEvent).toHaveBeenCalledWith(
+ expect.objectContaining({
+ category: MetaMetricsEvents.WHATS_HAPPENING_CLOSED,
+ properties: expect.objectContaining({
+ event_id: mockItem.id,
+ card_index: 0,
+ }),
+ }),
+ );
+ });
});
diff --git a/app/components/Views/WhatsHappeningDetailView/WhatsHappeningDetailView.tsx b/app/components/Views/WhatsHappeningDetailView/WhatsHappeningDetailView.tsx
index 562223eb544..84c15607c1b 100644
--- a/app/components/Views/WhatsHappeningDetailView/WhatsHappeningDetailView.tsx
+++ b/app/components/Views/WhatsHappeningDetailView/WhatsHappeningDetailView.tsx
@@ -24,9 +24,12 @@ import { strings } from '../../../../locales/i18n';
import { useWhatsHappening } from '../Homepage/Sections/WhatsHappening/hooks';
import { WhatsHappeningCardSkeleton } from '../Homepage/Sections/WhatsHappening/components';
import { MAX_ITEMS_DISPLAYED } from '../Homepage/Sections/WhatsHappening/constants';
+import { getWhatsHappeningEventProps } from '../Homepage/Sections/WhatsHappening/eventProperties';
import ErrorState from '../Homepage/components/ErrorState/ErrorState';
import WhatsHappeningExpandedCard from './components/WhatsHappeningExpandedCard';
import PageIndicator from './components/PageIndicator';
+import { MetaMetricsEvents } from '../../../core/Analytics';
+import { useAnalytics } from '../../hooks/useAnalytics/useAnalytics';
const { width: SCREEN_WIDTH } = Dimensions.get('window');
@@ -58,6 +61,9 @@ const WhatsHappeningDetailView = () => {
const [currentIndex, setCurrentIndex] = useState(initialIndex);
const [cardHeight, setCardHeight] = useState(0);
const scrollViewRef = useRef(null);
+ const hasTrackedViewRef = useRef(false);
+ const previousIndexRef = useRef(initialIndex);
+ const { trackEvent, createEventBuilder } = useAnalytics();
const handleCarouselLayout = useCallback((e: LayoutChangeEvent) => {
const { height } = e.nativeEvent.layout;
@@ -78,17 +84,60 @@ const WhatsHappeningDetailView = () => {
}
}, [initialIndex, isLoading, cardHeight]);
+ useEffect(() => {
+ if (
+ !isLoading &&
+ !hasTrackedViewRef.current &&
+ items.length > 0 &&
+ items[initialIndex]
+ ) {
+ hasTrackedViewRef.current = true;
+ trackEvent(
+ createEventBuilder(MetaMetricsEvents.WHATS_HAPPENING_VIEWED)
+ .addProperties(
+ getWhatsHappeningEventProps(items[initialIndex], initialIndex),
+ )
+ .build(),
+ );
+ }
+ }, [isLoading, items, initialIndex, trackEvent, createEventBuilder]);
+
const handleBackPress = useCallback(() => {
+ const visible = items[currentIndex];
+ if (visible) {
+ trackEvent(
+ createEventBuilder(MetaMetricsEvents.WHATS_HAPPENING_CLOSED)
+ .addProperties(getWhatsHappeningEventProps(visible, currentIndex))
+ .build(),
+ );
+ }
navigation.goBack();
- }, [navigation]);
+ }, [navigation, items, currentIndex, trackEvent, createEventBuilder]);
const handleScrollEnd = useCallback(
(event: NativeSyntheticEvent) => {
const offsetX = event.nativeEvent.contentOffset.x;
- const index = Math.round(offsetX / SNAP_INTERVAL);
- setCurrentIndex(Math.max(0, Math.min(index, items.length - 1)));
+ const index = Math.max(
+ 0,
+ Math.min(Math.round(offsetX / SNAP_INTERVAL), items.length - 1),
+ );
+
+ const prev = previousIndexRef.current;
+ if (index !== prev) {
+ const newItem = items[index];
+ if (newItem) {
+ trackEvent(
+ createEventBuilder(MetaMetricsEvents.WHATS_HAPPENING_VIEWED)
+ .addProperties(getWhatsHappeningEventProps(newItem, index))
+ .build(),
+ );
+ }
+ previousIndexRef.current = index;
+ }
+
+ setCurrentIndex(index);
},
- [items.length],
+ [items, trackEvent, createEventBuilder],
);
const hasError = !isLoading && items.length === 0 && !!error;
@@ -151,10 +200,11 @@ const WhatsHappeningDetailView = () => {
testID="whats-happening-detail-carousel"
>
{cardHeight > 0 &&
- items.map((item) => (
+ items.map((item, index) => (
diff --git a/app/components/Views/WhatsHappeningDetailView/components/PerpsRow.test.tsx b/app/components/Views/WhatsHappeningDetailView/components/PerpsRow.test.tsx
index d5cc96b1dcd..15504e180f3 100644
--- a/app/components/Views/WhatsHappeningDetailView/components/PerpsRow.test.tsx
+++ b/app/components/Views/WhatsHappeningDetailView/components/PerpsRow.test.tsx
@@ -4,8 +4,17 @@ import renderWithProvider from '../../../../util/test/renderWithProvider';
import PerpsRow from './PerpsRow';
import Routes from '../../../../constants/navigation/Routes';
import type { RelatedAsset } from '@metamask/ai-controllers';
+import type { WhatsHappeningItem } from '../../Homepage/Sections/WhatsHappening/types';
+import { MetaMetricsEvents } from '../../../../core/Analytics/MetaMetrics.events';
const mockNavigate = jest.fn();
+const mockTrackEvent = jest.fn();
+const mockCreateEventBuilder = jest.fn((eventName: string) => ({
+ addProperties: jest.fn((properties: Record) => ({
+ build: jest.fn(() => ({ category: eventName, properties })),
+ })),
+ build: jest.fn(() => ({ category: eventName })),
+}));
jest.mock('@react-navigation/native', () => {
const actual = jest.requireActual('@react-navigation/native');
@@ -19,6 +28,13 @@ jest.mock('../utils/getRelatedAssetImageSource', () => ({
getRelatedAssetImageSource: jest.fn(() => undefined),
}));
+jest.mock('../../../hooks/useAnalytics/useAnalytics', () => ({
+ useAnalytics: () => ({
+ trackEvent: mockTrackEvent,
+ createEventBuilder: mockCreateEventBuilder,
+ }),
+}));
+
const perpsOnlyAsset: RelatedAsset = {
sourceAssetId: 'tsla',
symbol: 'TSLA',
@@ -35,23 +51,40 @@ const dualAsset: RelatedAsset = {
hlPerpsMarket: ['BTC'],
};
+const mockItem: WhatsHappeningItem = {
+ id: 'trend-3',
+ title: 'TSLA earnings',
+ description: '...',
+ date: '2026-03-15T10:00:00.000Z',
+ category: 'macro',
+ impact: 'positive',
+ relatedAssets: [perpsOnlyAsset],
+ articles: [],
+};
+
describe('PerpsRow', () => {
beforeEach(() => {
jest.clearAllMocks();
});
it('renders the asset symbol', () => {
- renderWithProvider();
+ renderWithProvider(
+ ,
+ );
expect(screen.getByText('TSLA')).toBeOnTheScreen();
});
it('renders the Trade button', () => {
- renderWithProvider();
+ renderWithProvider(
+ ,
+ );
expect(screen.getByText('Trade')).toBeOnTheScreen();
});
it('navigates to PerpsMarketDetails with minimal market payload on Trade press', () => {
- renderWithProvider();
+ renderWithProvider(
+ ,
+ );
fireEvent.press(screen.getByText('Trade'));
expect(mockNavigate).toHaveBeenCalledWith(Routes.PERPS.ROOT, {
screen: Routes.PERPS.MARKET_DETAILS,
@@ -62,7 +95,9 @@ describe('PerpsRow', () => {
});
it('uses first hlPerpsMarket entry as the market symbol', () => {
- renderWithProvider();
+ renderWithProvider(
+ ,
+ );
fireEvent.press(screen.getByText('Trade'));
expect(mockNavigate).toHaveBeenCalledWith(Routes.PERPS.ROOT, {
screen: Routes.PERPS.MARKET_DETAILS,
@@ -77,8 +112,46 @@ describe('PerpsRow', () => {
...perpsOnlyAsset,
hlPerpsMarket: [],
};
- renderWithProvider();
+ renderWithProvider(
+ ,
+ );
fireEvent.press(screen.getByText('Trade'));
expect(mockNavigate).not.toHaveBeenCalled();
});
+
+ it('tracks Whats Happening Interaction with interaction_type=trade_pressed and asset details on Trade press', () => {
+ renderWithProvider(
+ ,
+ );
+ fireEvent.press(screen.getByText('Trade'));
+ expect(mockCreateEventBuilder).toHaveBeenCalledWith(
+ MetaMetricsEvents.WHATS_HAPPENING_INTERACTION,
+ );
+ expect(mockTrackEvent).toHaveBeenCalledWith(
+ expect.objectContaining({
+ category: MetaMetricsEvents.WHATS_HAPPENING_INTERACTION,
+ properties: expect.objectContaining({
+ interaction_type: 'trade_pressed',
+ asset_symbol: 'TSLA',
+ perps_market: 'xyz:TSLA',
+ event_id: 'trend-3',
+ card_index: 1,
+ category: 'macro',
+ impact: 'positive',
+ }),
+ }),
+ );
+ });
+
+ it('does not track Interaction when hlPerpsMarket is empty', () => {
+ const assetNoPerps: RelatedAsset = {
+ ...perpsOnlyAsset,
+ hlPerpsMarket: [],
+ };
+ renderWithProvider(
+ ,
+ );
+ fireEvent.press(screen.getByText('Trade'));
+ expect(mockCreateEventBuilder).not.toHaveBeenCalled();
+ });
});
diff --git a/app/components/Views/WhatsHappeningDetailView/components/PerpsRow.tsx b/app/components/Views/WhatsHappeningDetailView/components/PerpsRow.tsx
index 6a25fd84b10..6996cee0154 100644
--- a/app/components/Views/WhatsHappeningDetailView/components/PerpsRow.tsx
+++ b/app/components/Views/WhatsHappeningDetailView/components/PerpsRow.tsx
@@ -1,11 +1,18 @@
-import React from 'react';
+import React, { useCallback } from 'react';
import type { RelatedAsset } from '@metamask/ai-controllers';
import { strings } from '../../../../../locales/i18n';
+import { MetaMetricsEvents } from '../../../../core/Analytics';
+import { useAnalytics } from '../../../hooks/useAnalytics/useAnalytics';
+import { WhatsHappeningInteractionType } from '../../Homepage/Sections/WhatsHappening/constants';
+import { getWhatsHappeningEventProps } from '../../Homepage/Sections/WhatsHappening/eventProperties';
+import type { WhatsHappeningItem } from '../../Homepage/Sections/WhatsHappening/types';
import AssetRow from './AssetRow';
import useTradeNavigation from '../hooks/useTradeNavigation';
interface PerpsRowProps {
asset: RelatedAsset;
+ item: WhatsHappeningItem;
+ cardIndex: number;
}
/**
@@ -14,15 +21,39 @@ interface PerpsRowProps {
* the Perps market details view. Extracted as its own component so hooks can
* be called per-asset (hooks cannot be called inside a loop).
*/
-const PerpsRow: React.FC = ({ asset }) => {
+const PerpsRow: React.FC = ({ asset, item, cardIndex }) => {
const { handleTrade } = useTradeNavigation(asset);
+ const { trackEvent, createEventBuilder } = useAnalytics();
+
+ const handleTradeWithTracking = useCallback(() => {
+ if (!asset.hlPerpsMarket?.[0]) return;
+ trackEvent(
+ createEventBuilder(MetaMetricsEvents.WHATS_HAPPENING_INTERACTION)
+ .addProperties({
+ ...getWhatsHappeningEventProps(item, cardIndex),
+ interaction_type: WhatsHappeningInteractionType.TradePressed,
+ asset_symbol: asset.symbol,
+ perps_market: asset.hlPerpsMarket?.[0],
+ })
+ .build(),
+ );
+ handleTrade();
+ }, [
+ handleTrade,
+ asset.symbol,
+ asset.hlPerpsMarket,
+ item,
+ cardIndex,
+ trackEvent,
+ createEventBuilder,
+ ]);
return (
);
};
diff --git a/app/components/Views/WhatsHappeningDetailView/components/TokenRow.test.tsx b/app/components/Views/WhatsHappeningDetailView/components/TokenRow.test.tsx
index 33197ca9ec8..9f2ae4a4df0 100644
--- a/app/components/Views/WhatsHappeningDetailView/components/TokenRow.test.tsx
+++ b/app/components/Views/WhatsHappeningDetailView/components/TokenRow.test.tsx
@@ -3,9 +3,19 @@ import { screen, fireEvent } from '@testing-library/react-native';
import renderWithProvider from '../../../../util/test/renderWithProvider';
import TokenRow from './TokenRow';
import type { RelatedAsset } from '@metamask/ai-controllers';
+import type { WhatsHappeningItem } from '../../Homepage/Sections/WhatsHappening/types';
+import { MetaMetricsEvents } from '../../../../core/Analytics/MetaMetrics.events';
import Routes from '../../../../constants/navigation/Routes';
const mockGoToBuy = jest.fn();
+const mockTrackEvent = jest.fn();
+const mockCreateEventBuilder = jest.fn((eventName: string) => ({
+ addProperties: jest.fn((properties: Record) => ({
+ build: jest.fn(() => ({ category: eventName, properties })),
+ })),
+ build: jest.fn(() => ({ category: eventName })),
+}));
+
const mockNavigate = jest.fn();
jest.mock('../../../UI/Ramp/hooks/useRampNavigation', () => ({
@@ -24,6 +34,13 @@ jest.mock('../utils/getRelatedAssetImageSource', () => ({
getRelatedAssetImageSource: jest.fn(() => undefined),
}));
+jest.mock('../../../hooks/useAnalytics/useAnalytics', () => ({
+ useAnalytics: () => ({
+ trackEvent: mockTrackEvent,
+ createEventBuilder: mockCreateEventBuilder,
+ }),
+}));
+
const btcAsset: RelatedAsset = {
sourceAssetId: 'bitcoin',
symbol: 'BTC',
@@ -39,6 +56,24 @@ const dualAsset: RelatedAsset = {
hlPerpsMarket: ['ETH'],
};
+const perpsOnlyAsset: RelatedAsset = {
+ sourceAssetId: 'tsla',
+ symbol: 'TSLA',
+ name: 'Tesla',
+ caip19: [],
+};
+
+const mockItem: WhatsHappeningItem = {
+ id: 'trend-2',
+ title: 'BTC ETF inflows',
+ description: '...',
+ date: '2026-03-15T10:00:00.000Z',
+ category: 'macro',
+ impact: 'positive',
+ relatedAssets: [btcAsset],
+ articles: [],
+};
+
describe('TokenRow', () => {
beforeEach(() => {
jest.clearAllMocks();
@@ -46,17 +81,23 @@ describe('TokenRow', () => {
describe('when asset has only caip19 (no hlPerpsMarket)', () => {
it('renders the asset symbol', () => {
- renderWithProvider();
+ renderWithProvider(
+ ,
+ );
expect(screen.getByText('BTC')).toBeOnTheScreen();
});
it('renders the Buy button', () => {
- renderWithProvider();
+ renderWithProvider(
+ ,
+ );
expect(screen.getByText('Buy')).toBeOnTheScreen();
});
it('calls goToBuy with the first caip19 identifier on Buy press', () => {
- renderWithProvider();
+ renderWithProvider(
+ ,
+ );
fireEvent.press(screen.getByText('Buy'));
expect(mockGoToBuy).toHaveBeenCalledWith({
assetId: 'eip155:1/slip44:0',
@@ -64,15 +105,27 @@ describe('TokenRow', () => {
});
});
+ it('calls goToBuy with assetId undefined when caip19 is empty', () => {
+ renderWithProvider(
+ ,
+ );
+ fireEvent.press(screen.getByText('Buy'));
+ expect(mockGoToBuy).toHaveBeenCalledWith({ assetId: undefined });
+ });
+
describe('when asset has hlPerpsMarket (dual asset)', () => {
it('renders the Trade button instead of Buy', () => {
- renderWithProvider();
+ renderWithProvider(
+ ,
+ );
expect(screen.getByText('Trade')).toBeOnTheScreen();
expect(screen.queryByText('Buy')).toBeNull();
});
it('navigates to Perps market details on Trade press', () => {
- renderWithProvider();
+ renderWithProvider(
+ ,
+ );
fireEvent.press(screen.getByText('Trade'));
expect(mockNavigate).toHaveBeenCalledWith(Routes.PERPS.ROOT, {
screen: Routes.PERPS.MARKET_DETAILS,
@@ -83,9 +136,50 @@ describe('TokenRow', () => {
});
it('does not call goToBuy when Trade is pressed', () => {
- renderWithProvider();
+ renderWithProvider(
+ ,
+ );
fireEvent.press(screen.getByText('Trade'));
expect(mockGoToBuy).not.toHaveBeenCalled();
});
});
+
+ it('tracks Whats Happening Interaction with interaction_type=buy_pressed and asset details on Buy press', () => {
+ renderWithProvider(
+ ,
+ );
+ fireEvent.press(screen.getByText('Buy'));
+ expect(mockCreateEventBuilder).toHaveBeenCalledWith(
+ MetaMetricsEvents.WHATS_HAPPENING_INTERACTION,
+ );
+ expect(mockTrackEvent).toHaveBeenCalledWith(
+ expect.objectContaining({
+ category: MetaMetricsEvents.WHATS_HAPPENING_INTERACTION,
+ properties: expect.objectContaining({
+ interaction_type: 'buy_pressed',
+ asset_symbol: 'BTC',
+ asset_caip19: 'eip155:1/slip44:0',
+ event_id: 'trend-2',
+ card_index: 2,
+ category: 'macro',
+ impact: 'positive',
+ }),
+ }),
+ );
+ });
+
+ it('tracks Interaction without asset_caip19 when caip19 is empty', () => {
+ renderWithProvider(
+ ,
+ );
+ fireEvent.press(screen.getByText('Buy'));
+ const addPropertiesCall = mockCreateEventBuilder.mock.results[0]?.value
+ ?.addProperties as jest.Mock | undefined;
+ const builtProperties = addPropertiesCall?.mock?.calls?.[0]?.[0] as
+ | Record
+ | undefined;
+ expect(builtProperties?.interaction_type).toBe('buy_pressed');
+ expect(builtProperties?.asset_symbol).toBe('TSLA');
+ expect(builtProperties).not.toHaveProperty('asset_caip19');
+ });
});
diff --git a/app/components/Views/WhatsHappeningDetailView/components/TokenRow.tsx b/app/components/Views/WhatsHappeningDetailView/components/TokenRow.tsx
index 26394755c5b..4a29ff051fc 100644
--- a/app/components/Views/WhatsHappeningDetailView/components/TokenRow.tsx
+++ b/app/components/Views/WhatsHappeningDetailView/components/TokenRow.tsx
@@ -2,11 +2,18 @@ import React, { useCallback } from 'react';
import type { RelatedAsset } from '@metamask/ai-controllers';
import { strings } from '../../../../../locales/i18n';
import { useRampNavigation } from '../../../UI/Ramp/hooks/useRampNavigation';
+import { MetaMetricsEvents } from '../../../../core/Analytics';
+import { useAnalytics } from '../../../hooks/useAnalytics/useAnalytics';
+import { WhatsHappeningInteractionType } from '../../Homepage/Sections/WhatsHappening/constants';
+import { getWhatsHappeningEventProps } from '../../Homepage/Sections/WhatsHappening/eventProperties';
+import type { WhatsHappeningItem } from '../../Homepage/Sections/WhatsHappening/types';
import AssetRow from './AssetRow';
import useTradeNavigation from '../hooks/useTradeNavigation';
interface TokenRowProps {
asset: RelatedAsset;
+ item: WhatsHappeningItem;
+ cardIndex: number;
}
/**
@@ -16,14 +23,55 @@ interface TokenRowProps {
* Ramp buy flow. Extracted as its own component so hooks can be called
* per-asset (hooks cannot be called inside a loop).
*/
-const TokenRow: React.FC = ({ asset }) => {
+const TokenRow: React.FC = ({ asset, item, cardIndex }) => {
const { goToBuy } = useRampNavigation();
+ const { trackEvent, createEventBuilder } = useAnalytics();
const { handleTrade, canTrade } = useTradeNavigation(asset);
+ const handleTradeWithTracking = useCallback(() => {
+ trackEvent(
+ createEventBuilder(MetaMetricsEvents.WHATS_HAPPENING_INTERACTION)
+ .addProperties({
+ ...getWhatsHappeningEventProps(item, cardIndex),
+ interaction_type: WhatsHappeningInteractionType.TradePressed,
+ asset_symbol: asset.symbol,
+ perps_market: asset.hlPerpsMarket?.[0],
+ })
+ .build(),
+ );
+ handleTrade();
+ }, [
+ handleTrade,
+ asset.symbol,
+ asset.hlPerpsMarket,
+ item,
+ cardIndex,
+ trackEvent,
+ createEventBuilder,
+ ]);
+
const handleBuy = useCallback(() => {
const assetId = asset.caip19?.[0];
+ trackEvent(
+ createEventBuilder(MetaMetricsEvents.WHATS_HAPPENING_INTERACTION)
+ .addProperties({
+ ...getWhatsHappeningEventProps(item, cardIndex),
+ interaction_type: WhatsHappeningInteractionType.BuyPressed,
+ asset_symbol: asset.symbol,
+ ...(assetId ? { asset_caip19: assetId } : {}),
+ })
+ .build(),
+ );
goToBuy({ assetId });
- }, [goToBuy, asset.caip19]);
+ }, [
+ goToBuy,
+ asset.caip19,
+ asset.symbol,
+ item,
+ cardIndex,
+ trackEvent,
+ createEventBuilder,
+ ]);
if (canTrade) {
return (
@@ -31,7 +79,7 @@ const TokenRow: React.FC = ({ asset }) => {
asset={asset}
actionLabel={strings('bottom_nav.trade')}
accessibilityLabel={`${strings('bottom_nav.trade')} ${asset.symbol}`}
- onAction={handleTrade}
+ onAction={handleTradeWithTracking}
/>
);
}
diff --git a/app/components/Views/WhatsHappeningDetailView/components/WhatsHappeningExpandedCard.test.tsx b/app/components/Views/WhatsHappeningDetailView/components/WhatsHappeningExpandedCard.test.tsx
index 321367265c8..88c2498617c 100644
--- a/app/components/Views/WhatsHappeningDetailView/components/WhatsHappeningExpandedCard.test.tsx
+++ b/app/components/Views/WhatsHappeningDetailView/components/WhatsHappeningExpandedCard.test.tsx
@@ -8,6 +8,18 @@ import Routes from '../../../../constants/navigation/Routes';
const mockNavigate = jest.fn();
const mockGoToBuy = jest.fn();
+jest.mock('../../../hooks/useAnalytics/useAnalytics', () => ({
+ useAnalytics: () => ({
+ trackEvent: jest.fn(),
+ createEventBuilder: jest.fn((eventName: string) => ({
+ addProperties: jest.fn(() => ({
+ build: jest.fn(() => ({ category: eventName })),
+ })),
+ build: jest.fn(() => ({ category: eventName })),
+ })),
+ }),
+}));
+
jest.mock('@react-navigation/native', () => {
const actual = jest.requireActual('@react-navigation/native');
return {
@@ -81,6 +93,7 @@ describe('WhatsHappeningExpandedCard', () => {
renderWithProvider(
,
@@ -93,6 +106,7 @@ describe('WhatsHappeningExpandedCard', () => {
renderWithProvider(
,
@@ -105,6 +119,7 @@ describe('WhatsHappeningExpandedCard', () => {
renderWithProvider(
,
@@ -117,6 +132,7 @@ describe('WhatsHappeningExpandedCard', () => {
renderWithProvider(
,
@@ -131,6 +147,7 @@ describe('WhatsHappeningExpandedCard', () => {
renderWithProvider(
,
@@ -145,6 +162,7 @@ describe('WhatsHappeningExpandedCard', () => {
renderWithProvider(
,
@@ -158,6 +176,7 @@ describe('WhatsHappeningExpandedCard', () => {
renderWithProvider(
,
@@ -172,6 +191,7 @@ describe('WhatsHappeningExpandedCard', () => {
renderWithProvider(
,
@@ -185,6 +205,7 @@ describe('WhatsHappeningExpandedCard', () => {
renderWithProvider(
,
@@ -200,6 +221,7 @@ describe('WhatsHappeningExpandedCard', () => {
renderWithProvider(
,
@@ -214,6 +236,7 @@ describe('WhatsHappeningExpandedCard', () => {
renderWithProvider(
,
@@ -227,6 +250,7 @@ describe('WhatsHappeningExpandedCard', () => {
renderWithProvider(
,
diff --git a/app/components/Views/WhatsHappeningDetailView/components/WhatsHappeningExpandedCard.tsx b/app/components/Views/WhatsHappeningDetailView/components/WhatsHappeningExpandedCard.tsx
index 8359a15efcf..c86dcafef7a 100644
--- a/app/components/Views/WhatsHappeningDetailView/components/WhatsHappeningExpandedCard.tsx
+++ b/app/components/Views/WhatsHappeningDetailView/components/WhatsHappeningExpandedCard.tsx
@@ -30,6 +30,7 @@ import WhatsHappeningSourcesBottomSheet from './WhatsHappeningSourcesBottomSheet
interface WhatsHappeningExpandedCardProps {
item: WhatsHappeningItem;
+ cardIndex: number;
cardWidth: number;
/** Height of the carousel container — used to give every card the same fixed height. */
cardHeight: number;
@@ -37,6 +38,7 @@ interface WhatsHappeningExpandedCardProps {
const WhatsHappeningExpandedCard: React.FC = ({
item,
+ cardIndex,
cardWidth,
cardHeight,
}) => {
@@ -114,7 +116,12 @@ const WhatsHappeningExpandedCard: React.FC = ({
{item.relatedAssets
.filter((asset) => asset.caip19?.length)
.map((asset) => (
-
+
))}
)}
@@ -138,7 +145,12 @@ const WhatsHappeningExpandedCard: React.FC = ({
asset.hlPerpsMarket?.length && !asset.caip19?.length,
)
.map((asset) => (
-
+
))}
)}
@@ -195,6 +207,8 @@ const WhatsHappeningExpandedCard: React.FC = ({
setSourcesVisible(false)}
articles={item.articles}
+ item={item}
+ cardIndex={cardIndex}
/>
)}
diff --git a/app/components/Views/WhatsHappeningDetailView/components/WhatsHappeningSourcesBottomSheet.test.tsx b/app/components/Views/WhatsHappeningDetailView/components/WhatsHappeningSourcesBottomSheet.test.tsx
index b86e82ede8c..9be1cf1efa9 100644
--- a/app/components/Views/WhatsHappeningDetailView/components/WhatsHappeningSourcesBottomSheet.test.tsx
+++ b/app/components/Views/WhatsHappeningDetailView/components/WhatsHappeningSourcesBottomSheet.test.tsx
@@ -3,6 +3,23 @@ import { Linking } from 'react-native';
import { screen, fireEvent } from '@testing-library/react-native';
import renderWithProvider from '../../../../util/test/renderWithProvider';
import WhatsHappeningSourcesBottomSheet from './WhatsHappeningSourcesBottomSheet';
+import type { WhatsHappeningItem } from '../../Homepage/Sections/WhatsHappening/types';
+import { MetaMetricsEvents } from '../../../../core/Analytics/MetaMetrics.events';
+
+const mockTrackEvent = jest.fn();
+const mockCreateEventBuilder = jest.fn((eventName: string) => ({
+ addProperties: jest.fn((properties: Record) => ({
+ build: jest.fn(() => ({ category: eventName, properties })),
+ })),
+ build: jest.fn(() => ({ category: eventName })),
+}));
+
+jest.mock('../../../hooks/useAnalytics/useAnalytics', () => ({
+ useAnalytics: () => ({
+ trackEvent: mockTrackEvent,
+ createEventBuilder: mockCreateEventBuilder,
+ }),
+}));
jest.mock(
'../../../../component-library/components/BottomSheets/BottomSheet',
@@ -64,6 +81,24 @@ const articles = [
},
];
+const mockItem: WhatsHappeningItem = {
+ id: 'trend-7',
+ title: 'Fed pauses rates',
+ description: '...',
+ date: '2026-03-15T10:00:00.000Z',
+ category: 'macro',
+ impact: 'positive',
+ relatedAssets: [
+ {
+ sourceAssetId: 'btc',
+ symbol: 'BTC',
+ name: 'Bitcoin',
+ caip19: ['eip155:1/slip44:0'],
+ },
+ ],
+ articles: articles as never,
+};
+
describe('WhatsHappeningSourcesBottomSheet', () => {
beforeEach(() => {
jest.clearAllMocks();
@@ -75,6 +110,8 @@ describe('WhatsHappeningSourcesBottomSheet', () => {
,
);
expect(screen.getByText('coindesk.com')).toBeOnTheScreen();
@@ -86,6 +123,8 @@ describe('WhatsHappeningSourcesBottomSheet', () => {
,
);
fireEvent.press(screen.getByText('coindesk.com'));
@@ -98,6 +137,8 @@ describe('WhatsHappeningSourcesBottomSheet', () => {
,
);
fireEvent.press(screen.getByText('coindesk.com'));
@@ -109,6 +150,8 @@ describe('WhatsHappeningSourcesBottomSheet', () => {
,
);
expect(screen.getByText('News sources')).toBeOnTheScreen();
@@ -116,8 +159,58 @@ describe('WhatsHappeningSourcesBottomSheet', () => {
it('renders no article rows when articles array is empty', () => {
renderWithProvider(
- ,
+ ,
);
expect(screen.queryByText('coindesk.com')).toBeNull();
});
+
+ it('tracks Whats Happening Interaction (source_click) with the article URL on row press', () => {
+ renderWithProvider(
+ ,
+ );
+ fireEvent.press(screen.getByText('coindesk.com'));
+ expect(mockCreateEventBuilder).toHaveBeenCalledWith(
+ MetaMetricsEvents.WHATS_HAPPENING_INTERACTION,
+ );
+ expect(mockTrackEvent).toHaveBeenCalledWith(
+ expect.objectContaining({
+ category: MetaMetricsEvents.WHATS_HAPPENING_INTERACTION,
+ properties: expect.objectContaining({
+ interaction_type: 'source_click',
+ source: 'https://coindesk.com/fed-pauses',
+ event_id: 'trend-7',
+ card_index: 3,
+ category: 'macro',
+ impact: 'positive',
+ asset_symbols: ['BTC'],
+ }),
+ }),
+ );
+ });
+
+ it('still tracks the source_click interaction even when the URL is unsafe', () => {
+ mockIsSafeUrl.mockReturnValue(false);
+ renderWithProvider(
+ ,
+ );
+ fireEvent.press(screen.getByText('coindesk.com'));
+ expect(mockCreateEventBuilder).toHaveBeenCalledWith(
+ MetaMetricsEvents.WHATS_HAPPENING_INTERACTION,
+ );
+ });
});
diff --git a/app/components/Views/WhatsHappeningDetailView/components/WhatsHappeningSourcesBottomSheet.tsx b/app/components/Views/WhatsHappeningDetailView/components/WhatsHappeningSourcesBottomSheet.tsx
index 9f6a48a63e4..d9c2561cd6c 100644
--- a/app/components/Views/WhatsHappeningDetailView/components/WhatsHappeningSourcesBottomSheet.tsx
+++ b/app/components/Views/WhatsHappeningDetailView/components/WhatsHappeningSourcesBottomSheet.tsx
@@ -15,23 +15,43 @@ import BottomSheetHeader from '../../../../component-library/components/BottomSh
import { strings } from '../../../../../locales/i18n';
import ArticleRow from '../../../UI/MarketInsights/components/ArticleRow';
import { isSafeUrl } from '../../../UI/MarketInsights/utils/marketInsightsFormatting';
+import { MetaMetricsEvents } from '../../../../core/Analytics';
+import { useAnalytics } from '../../../hooks/useAnalytics/useAnalytics';
+import { WhatsHappeningInteractionType } from '../../Homepage/Sections/WhatsHappening/constants';
+import { getWhatsHappeningEventProps } from '../../Homepage/Sections/WhatsHappening/eventProperties';
+import type { WhatsHappeningItem } from '../../Homepage/Sections/WhatsHappening/types';
interface WhatsHappeningSourcesBottomSheetProps {
onClose: () => void;
articles: Article[];
+ item: WhatsHappeningItem;
+ cardIndex: number;
}
const WhatsHappeningSourcesBottomSheet: React.FC<
WhatsHappeningSourcesBottomSheetProps
-> = ({ onClose, articles }) => {
+> = ({ onClose, articles, item, cardIndex }) => {
const tw = useTailwind();
const bottomSheetRef = useRef(null);
+ const { trackEvent, createEventBuilder } = useAnalytics();
- const handleSourcePress = useCallback((url: string) => {
- if (isSafeUrl(url)) {
- Linking.openURL(url);
- }
- }, []);
+ const handleSourcePress = useCallback(
+ (url: string) => {
+ trackEvent(
+ createEventBuilder(MetaMetricsEvents.WHATS_HAPPENING_INTERACTION)
+ .addProperties({
+ ...getWhatsHappeningEventProps(item, cardIndex),
+ interaction_type: WhatsHappeningInteractionType.SourceClick,
+ source: url,
+ })
+ .build(),
+ );
+ if (isSafeUrl(url)) {
+ Linking.openURL(url);
+ }
+ },
+ [item, cardIndex, trackEvent, createEventBuilder],
+ );
return (
Date: Thu, 7 May 2026 13:22:42 +0200
Subject: [PATCH 13/13] =?UTF-8?q?ci(INFRA-3593):=20Phase=201=20=E2=80=94?=
=?UTF-8?q?=20Namespace=20cache=20for=20Linux=20CI=20trial=20(#29716)?=
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
## **Description**
INFRA-3593 Phase 1 — adds Namespace Cache Volumes integration to the
Linux CI jobs on the `namespace-runner-trial` branch.
When `runner_provider: namespace` is dispatched, the 8 Linux CI jobs
that install dependencies now:
1. Mount a Namespace Cache Volume via `nscloud-cache-action` covering
`~/.cache/yarn`, `.metamask`, `node_modules`, `.yarn/cache`, and
`.yarn/install-state.gz`
2. Skip `actions/setup-node` Yarn caching (set to empty string) to avoid
duplicate network-backed cache traffic
3. Skip `actions/cache` for `node_modules` in `component-view-tests` and
`merge-unit-and-component-view-tests` (Namespace cache already covers
it)
When `runner_provider: current` (the default on every existing trigger),
all ternaries collapse to their prior values and behavior is
byte-identical to the base branch.
**No job is renamed. No default is changed.** This is an additive,
opt-in change activated only via manual `workflow_dispatch` with
`runner_provider: namespace`.
## **Changelog**
CHANGELOG entry: null
## **Related issues**
Fixes: INFRA-3593 (parent epic INFRA-3511)
Refs: INFRA-3592 (Phase 0, PR #29557)
## **Manual testing steps**
```gherkin
Feature: Namespace Cache Volumes on Linux CI
Scenario: dispatch with namespace provider — cache volumes active
Given the branch phase1-namespace-linux-cache
When user runs `gh workflow run ci.yml --ref phase1-namespace-linux-cache -f runner_provider=namespace`
Then all 8 Linux CI jobs with dependencies use nscloud-cache-action
And actions/setup-node Yarn caching is disabled (no duplicate cache traffic)
And all jobs pass across every matrix shard (unit-tests x10, component-view-tests x2, scripts x6)
Scenario: dispatch with current provider — byte-identical to base
Given the branch phase1-namespace-linux-cache
When user runs `gh workflow run ci.yml --ref phase1-namespace-linux-cache -f runner_provider=current`
Then nscloud-cache-action steps are skipped (if condition is false)
And actions/setup-node uses cache: yarn as before
And actions/cache for node_modules runs as before
And all jobs pass on GitHub-hosted runners
Scenario: implicit current via PR/push trigger
Given a push or pull_request event (no workflow_dispatch)
Then inputs.runner_provider is undefined/empty
And all ternaries collapse to existing behavior
```
## **Screenshots/Recordings**
### **Before**
N/A
### **After**
N/A — CI infrastructure PR, no UI surface.
## **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)
N/A — workflow YAML only, no app code.
## **Pre-merge reviewer checklist**
- [ ] I've manually tested the PR (e.g. pull and build branch, run the
app, test code being changed).
- [ ] I confirm that this PR addresses all acceptance criteria described
in the ticket it closes and includes the necessary testing evidence such
as recordings and or screenshots.
Made with [Cursor](https://cursor.com)
---
> [!NOTE]
> **Medium Risk**
> Touches many GitHub Actions workflows to optionally switch runner
labels and caching behavior, which could break CI execution or cause
cache-related flakiness when enabled. Default behavior remains unchanged
unless `runner_provider=namespace` is explicitly selected.
>
> **Overview**
> Introduces a `runner_provider` input (with `workflow_dispatch` choices
where relevant) to route jobs between existing runners and new
`namespace-profile-*` runner labels across `ci.yml`, reusable build
workflows, and E2E smoke/regression workflows.
>
> When `runner_provider=namespace`, Linux CI jobs that install
dependencies mount Namespace cache volumes via
`namespacelabs/nscloud-cache-action`, disable `actions/setup-node` Yarn
caching, and skip `actions/cache`-based `node_modules` restores in
coverage-merge/component-view jobs; Node memory limits are also reduced
on Namespace runners.
>
> Updates `actionlint` configuration to allow the new Namespace runner
profile labels.
>
> Reviewed by [Cursor Bugbot](https://cursor.com/bugbot) for commit
3a52111227a4f9e4d7e94f2eb3d213595d1474a4. Bugbot is set up for automated
code reviews on this repo. Configure
[here](https://www.cursor.com/dashboard/bugbot).
---------
Co-authored-by: Jose Luque
Co-authored-by: José Manuel <6741785+jluque0101@users.noreply.github.com>
Co-authored-by: Cursor
---
.github/workflows/ci.yml | 96 +++++++++++++++++++++++++++++++++++-----
1 file changed, 85 insertions(+), 11 deletions(-)
diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml
index 8286e156322..381086d45eb 100644
--- a/.github/workflows/ci.yml
+++ b/.github/workflows/ci.yml
@@ -94,10 +94,19 @@ jobs:
- get_requirements
steps:
- uses: actions/checkout@v6
+ - name: Configure Namespace cache
+ if: ${{ inputs.runner_provider == 'namespace' }}
+ uses: namespacelabs/nscloud-cache-action@15799a6b54e5765f85b2aac25b3f0df43ed571c0 # v1
+ with:
+ path: |
+ ~/.cache/yarn
+ .metamask
+ node_modules
+ .yarn/cache
- uses: actions/setup-node@v6
with:
node-version-file: '.nvmrc'
- cache: yarn
+ cache: ${{ inputs.runner_provider != 'namespace' && 'yarn' || '' }}
- name: Install Yarn dependencies with retry
uses: nick-fields/retry@ce71cc2ab81d554ebbe88c79ab5975992d79ba08 #v3.0.2
with:
@@ -130,10 +139,19 @@ jobs:
- get_requirements
steps:
- uses: actions/checkout@v6
+ - name: Configure Namespace cache
+ if: ${{ inputs.runner_provider == 'namespace' }}
+ uses: namespacelabs/nscloud-cache-action@15799a6b54e5765f85b2aac25b3f0df43ed571c0 # v1
+ with:
+ path: |
+ ~/.cache/yarn
+ .metamask
+ node_modules
+ .yarn/cache
- uses: actions/setup-node@v6
with:
node-version-file: '.nvmrc'
- cache: yarn
+ cache: ${{ inputs.runner_provider != 'namespace' && 'yarn' || '' }}
- name: Install Yarn dependencies with retry
uses: nick-fields/retry@ce71cc2ab81d554ebbe88c79ab5975992d79ba08 #v3.0.2
with:
@@ -170,10 +188,19 @@ jobs:
- uses: actions/checkout@v6
with:
fetch-depth: 2
+ - name: Configure Namespace cache
+ if: ${{ inputs.runner_provider == 'namespace' }}
+ uses: namespacelabs/nscloud-cache-action@15799a6b54e5765f85b2aac25b3f0df43ed571c0 # v1
+ with:
+ path: |
+ ~/.cache/yarn
+ .metamask
+ node_modules
+ .yarn/cache
- uses: actions/setup-node@v6
with:
node-version-file: '.nvmrc'
- cache: yarn
+ cache: ${{ inputs.runner_provider != 'namespace' && 'yarn' || '' }}
- name: Install Yarn dependencies with retry
uses: nick-fields/retry@ce71cc2ab81d554ebbe88c79ab5975992d79ba08 #v3.0.2
with:
@@ -205,10 +232,19 @@ jobs:
statuses: write
steps:
- uses: actions/checkout@v6
+ - name: Configure Namespace cache
+ if: ${{ inputs.runner_provider == 'namespace' }}
+ uses: namespacelabs/nscloud-cache-action@15799a6b54e5765f85b2aac25b3f0df43ed571c0 # v1
+ with:
+ path: |
+ ~/.cache/yarn
+ .metamask
+ node_modules
+ .yarn/cache
- uses: actions/setup-node@v6
with:
node-version-file: '.nvmrc'
- cache: yarn
+ cache: ${{ inputs.runner_provider != 'namespace' && 'yarn' || '' }}
- name: Install Yarn dependencies with retry
uses: nick-fields/retry@ce71cc2ab81d554ebbe88c79ab5975992d79ba08 #v3.0.2
with:
@@ -469,10 +505,19 @@ jobs:
shard: [1, 2, 3, 4, 5, 6, 7, 8, 9, 10]
steps:
- uses: actions/checkout@v6
+ - name: Configure Namespace cache
+ if: ${{ inputs.runner_provider == 'namespace' }}
+ uses: namespacelabs/nscloud-cache-action@15799a6b54e5765f85b2aac25b3f0df43ed571c0 # v1
+ with:
+ path: |
+ ~/.cache/yarn
+ .metamask
+ node_modules
+ .yarn/cache
- uses: actions/setup-node@v6
with:
node-version-file: '.nvmrc'
- cache: yarn
+ cache: ${{ inputs.runner_provider != 'namespace' && 'yarn' || '' }}
- name: Install Yarn dependencies with retry
uses: nick-fields/retry@ce71cc2ab81d554ebbe88c79ab5975992d79ba08 #v3.0.2
with:
@@ -488,7 +533,7 @@ jobs:
# in sync with the length of matrix.shard
- run: yarn test:unit --shard=${{ matrix.shard }}/10 --forceExit --silent --coverageReporters=json --json --outputFile=tests/results/unit-test-results-${{ matrix.shard }}.json
env:
- NODE_OPTIONS: --max_old_space_size=20480
+ NODE_OPTIONS: ${{ inputs.runner_provider == 'namespace' && '--max_old_space_size=12288' || '--max_old_space_size=20480' }}
- name: Rename coverage report and extract test count for this shard
shell: bash
run: |
@@ -520,7 +565,17 @@ jobs:
if: ${{ !cancelled() && github.event_name != 'merge_group' }}
steps:
- uses: actions/checkout@v6
+ - name: Configure Namespace cache
+ if: ${{ inputs.runner_provider == 'namespace' }}
+ uses: namespacelabs/nscloud-cache-action@15799a6b54e5765f85b2aac25b3f0df43ed571c0 # v1
+ with:
+ path: |
+ ~/.cache/yarn
+ .metamask
+ node_modules
+ .yarn/cache
- name: Restore node_modules cache
+ if: ${{ inputs.runner_provider != 'namespace' }}
id: cache-node-modules
uses: actions/cache@v4
with:
@@ -531,9 +586,9 @@ jobs:
- uses: actions/setup-node@v6
with:
node-version-file: '.nvmrc'
- cache: yarn
+ cache: ${{ inputs.runner_provider != 'namespace' && 'yarn' || '' }}
- name: Install Yarn dependencies with retry
- if: steps.cache-node-modules.outputs.cache-hit != 'true'
+ if: ${{ inputs.runner_provider == 'namespace' || steps.cache-node-modules.outputs.cache-hit != 'true' }}
uses: nick-fields/retry@ce71cc2ab81d554ebbe88c79ab5975992d79ba08 #v3.0.2
with:
timeout_minutes: 10
@@ -641,7 +696,17 @@ jobs:
shard: [1, 2]
steps:
- uses: actions/checkout@v6
+ - name: Configure Namespace cache
+ if: ${{ inputs.runner_provider == 'namespace' }}
+ uses: namespacelabs/nscloud-cache-action@15799a6b54e5765f85b2aac25b3f0df43ed571c0 # v1
+ with:
+ path: |
+ ~/.cache/yarn
+ .metamask
+ node_modules
+ .yarn/cache
- name: Restore node_modules cache
+ if: ${{ inputs.runner_provider != 'namespace' }}
id: cache-node-modules
uses: actions/cache@v4
with:
@@ -652,9 +717,9 @@ jobs:
- uses: actions/setup-node@v6
with:
node-version-file: '.nvmrc'
- cache: yarn
+ cache: ${{ inputs.runner_provider != 'namespace' && 'yarn' || '' }}
- name: Install Yarn dependencies with retry
- if: steps.cache-node-modules.outputs.cache-hit != 'true'
+ if: ${{ inputs.runner_provider == 'namespace' || steps.cache-node-modules.outputs.cache-hit != 'true' }}
uses: nick-fields/retry@ce71cc2ab81d554ebbe88c79ab5975992d79ba08 #v3.0.2
with:
timeout_minutes: 10
@@ -671,7 +736,7 @@ jobs:
--json \
--outputFile=tests/results/cv-test-results-${{ matrix.shard }}.json
env:
- NODE_OPTIONS: --max-old-space-size=20480
+ NODE_OPTIONS: ${{ inputs.runner_provider == 'namespace' && '--max-old-space-size=12288' || '--max-old-space-size=20480' }}
- name: Rename coverage report and extract test count for this shard
shell: bash
run: |
@@ -860,6 +925,15 @@ jobs:
- uses: actions/checkout@v6
with:
fetch-depth: 0 # SonarCloud needs a full checkout to perform necessary analysis
+ - name: Configure Namespace cache
+ if: ${{ inputs.runner_provider == 'namespace' }}
+ uses: namespacelabs/nscloud-cache-action@15799a6b54e5765f85b2aac25b3f0df43ed571c0 # v1
+ with:
+ path: |
+ ~/.cache/yarn
+ .metamask
+ node_modules
+ .yarn/cache
- uses: actions/setup-node@v6
with:
node-version-file: '.nvmrc'