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 ( + + + + + +