diff --git a/.agents/skills/e2e-test/SKILL.md b/.agents/skills/e2e-test/SKILL.md new file mode 100644 index 00000000000..bdbeb07681e --- /dev/null +++ b/.agents/skills/e2e-test/SKILL.md @@ -0,0 +1,92 @@ +--- +name: e2e-test +description: + Add and fix Detox E2E tests (smoke and regression) for MetaMask Mobile using + withFixtures, Page Objects, and tests/framework. Use when creating a new spec, + fixing a failing E2E test, or adding page objects and selectors. +--- + +# E2E Test Builder — Skill + +> **One source of truth** for adding Detox E2E tests to MetaMask Mobile. +> Applies to: Claude Code (`.claude/commands/e2e-test.md`), Cursor, Copilot, Windsurf, and other AI agents. + +**Before writing or changing any E2E code:** read this skill once, then open the reference(s) indicated by the decision tree for your task. + +## What This Skill Does + +Guides you through adding a new E2E regression or smoke test end-to-end: + +1. Plans the test (type, location, infrastructure needed) +2. Creates or reuses Page Objects and selectors +3. Writes the spec using the mandatory framework patterns and the **correct tag** (see Golden rule 8; check `tests/tags.js` and existing specs in the feature folder) +4. Runs lint and type checks +5. Executes the test locally via Detox +6. Iterates until the test passes + +Your job is to figure out whether the user needs to **write a new spec**, **fix a failing test**, or **add page objects/selectors**, then follow the corresponding path and open the relevant reference when that path indicates. + +**Decision tree — which reference to use:** + +``` +Task → What do you need? +├─ Write new spec or add test steps +│ → Open references/writing-tests.md (spec structure, templates, FixtureBuilder patterns) +│ → If you need POM/selectors: also open references/page-objects.md +│ → If you need API or feature-flag mocks: also open references/mocking.md +│ → After writing: run lint/tsc, then open references/running-tests.md to run and debug +│ +├─ Create or update Page Objects / selectors +│ → Open references/page-objects.md (POM structure, Matchers, Gestures, Assertions, selector conventions) +│ → When writing the spec: open references/writing-tests.md +│ +├─ Mock API or feature flags +│ → Open references/mocking.md (testSpecificMock, setupRemoteFeatureFlagsMock, setupMockRequest) +│ → When writing the spec: open references/writing-tests.md +│ +└─ Run tests, debug failures, or self-review + → Open references/running-tests.md (build check, detox commands, common failures, retry patterns) +``` + +Do not read the full reference files until the decision tree or workflow sends you there. + +--- + +## 10 Golden Rules + +1. **Always use `withFixtures`** — every spec must be wrapped; no exceptions +2. **Always use Page Object Model** — no `element(by.id())` in spec files +3. **Always import from `tests/framework/index.ts`** — never from individual files +4. **Always add `description`** to every `Gestures.*` and `Assertions.*` call +5. **Never use `TestHelpers.delay()`** — use `Assertions.*` which has auto-retry +6. **Use `FixtureBuilder` for state** — do not set state through UI interactions +7. **Selectors live in `*.testIds.ts`** (co-located) or `tests/selectors/` (legacy) +8. **Tag correctly** — Use the tag that matches your feature and test type. Options include `SmokeE2E`, `SmokeTrade`, `SmokePredictions`, `SmokePerps`, `SmokeConfirmations`, `RegressionTrade`, `RegressionWallet`, etc. Check **`tests/tags.js`** for the full list and descriptions, and **existing specs in the same feature folder** to see which tag they use. +9. **Descriptive test names** — no 'should' prefix (e.g., `'opens market details'`) +10. **Fix lint/tsc before running** — never run with known errors + +--- + +## Workflow Overview + +``` +Step 0 → Understand requirement + choose type (smoke/regression) +Step 1 → Discover / create Page Objects and selectors +Step 2 → Write the spec (withFixtures + POM + correct tag) +Step 3 → Lint + TSC (fix all errors) +Step 4 → Run detox test locally +Step 5 → Iterate (fix → lint → run) until green +``` + +--- + +## Reference files (when to use) + +Documentation is split by **action**. Open only the reference that matches what you are doing. + +| Action | File | When to open it | +| --------------------------------------------- | ---------------------------------------------------------- | ------------------------------------------------------------------------------------------------- | +| **Writing or updating a spec** | [references/writing-tests.md](references/writing-tests.md) | New spec file, spec structure, FixtureBuilder patterns, smoke/regression templates. | +| **Page Objects and selectors** | [references/page-objects.md](references/page-objects.md) | Create or update POM classes, selector/testId conventions, Matchers/Gestures/Assertions API. | +| **API and feature flag mocking** | [references/mocking.md](references/mocking.md) | testSpecificMock, setupRemoteFeatureFlagsMock, setupMockRequest, shared mock files. | +| **Running tests, debugging, fixing failures** | [references/running-tests.md](references/running-tests.md) | Build check, detox run commands, lint/tsc, common failures table, retry patterns, iteration loop. | diff --git a/.agents/skills/e2e-test/agents/openai.yaml b/.agents/skills/e2e-test/agents/openai.yaml new file mode 100644 index 00000000000..3acab8787d6 --- /dev/null +++ b/.agents/skills/e2e-test/agents/openai.yaml @@ -0,0 +1,4 @@ +interface: + display_name: "E2E Test" + short_description: "Add and fix Detox E2E smoke/regression tests for MetaMask Mobile." + default_prompt: "Use $e2e-test to add or fix E2E tests with withFixtures, Page Objects, and the tests/framework." diff --git a/.agents/skills/e2e-test/references/mocking.md b/.agents/skills/e2e-test/references/mocking.md new file mode 100644 index 00000000000..da5dd07ebf4 --- /dev/null +++ b/.agents/skills/e2e-test/references/mocking.md @@ -0,0 +1,141 @@ +# API & Feature Flag Mocking — Reference + +## How Mocking Works + +All E2E tests run with a proxy mock server. Requests not matched by a mock reach the real network, but the test framework warns you (and will soon enforce this). Always mock external APIs your feature calls. + +## testSpecificMock Pattern + +Pass to `withFixtures` to apply mocks only for that test: + +```typescript +import { Mockttp } from 'mockttp'; +import { setupRemoteFeatureFlagsMock } from '../../api-mocking/helpers/remoteFeatureFlagsHelper'; +import { setupMockRequest } from '../../api-mocking/mockHelpers'; + +const testSpecificMock = async (mockServer: Mockttp) => { + // Feature flags + await setupRemoteFeatureFlagsMock(mockServer, { myFeatureEnabled: true }); + + // GET request + await setupMockRequest(mockServer, { + requestMethod: 'GET', + url: 'https://api.example.com/data', + response: { items: [] }, + responseCode: 200, + }); +}; + +await withFixtures({ fixture: ..., testSpecificMock }, async () => { ... }); +``` + +## Feature Flag Mocking + +```typescript +import { setupRemoteFeatureFlagsMock } from '../../api-mocking/helpers/remoteFeatureFlagsHelper'; + +// Simple boolean flags +await setupRemoteFeatureFlagsMock(mockServer, { + predictTradingEnabled: true, + carouselBanners: false, +}); + +// Nested flags +await setupRemoteFeatureFlagsMock(mockServer, { + bridgeConfig: { support: true, refreshRate: 5000 }, +}); + +// Flask distribution +await setupRemoteFeatureFlagsMock(mockServer, { perpsEnabled: true }, 'flask'); + +// Combine predefined configs +import { confirmationsRedesignedFeatureFlags } from '../../api-mocking/mock-responses/feature-flags-mocks'; +await setupRemoteFeatureFlagsMock( + mockServer, + Object.assign({}, ...confirmationsRedesignedFeatureFlags, { myFlag: true }), +); +``` + +## HTTP Request Mocking + +```typescript +import { + setupMockRequest, + setupMockPostRequest, +} from '../../api-mocking/mockHelpers'; + +// GET +await setupMockRequest(mockServer, { + requestMethod: 'GET', + url: 'https://api.example.com/resource', + response: { data: [] }, + responseCode: 200, +}); + +// POST with body validation +await setupMockPostRequest( + mockServer, + 'https://api.example.com/submit', + { amount: '1000000000000000000' }, // expected request body + { success: true }, // response + { statusCode: 201, ignoreFields: ['timestamp', 'nonce'] }, +); +``` + +## Shared Mock Response Files + +For mocks used across multiple tests, create a file in `tests/api-mocking/mock-responses/`: + +```typescript +// tests/api-mocking/mock-responses/predict-mocks.ts +import { MockApiEndpoint } from '../framework/types'; + +export const PREDICT_MOCKS = { + GET: [ + { + urlEndpoint: 'https://predict.api.metamask.io/markets', + responseCode: 200, + response: { markets: [{ id: 'btc-usd', name: 'BTC above $100k?' }] }, + }, + ] as MockApiEndpoint[], +}; +``` + +Then pass directly to `withFixtures`: + +```typescript +await withFixtures({ fixture: ..., testSpecificMock: PREDICT_MOCKS }, async () => { ... }); +``` + +## Controller-Level Mocking (Advanced) + +Only needed when the feature uses SDKs with complex transport (WebSockets, custom protocols) that can't be intercepted at the HTTP level. + +- Implement a mixin in `tests/controller-mocking/mock-config/` +- See `tests/docs/CONTROLLER_MOCKING.md` for details +- Prefer HTTP-level mocking whenever possible + +## Debugging Unmocked Requests + +Check test output for warnings like: + +``` +⚠️ Unmocked request: GET https://api.example.com/resource +``` + +Add a mock for every such request to ensure test determinism. + +## Features using WebSockets or complex transport + +Some features depend on **WebSockets** or other non-HTTP transport (e.g. Perps/HyperLiquid, real-time data). The HTTP mock server cannot intercept these. The repo uses two patterns: + +1. **Controller-level mocking** — A mixin under `tests/controller-mocking/mock-config/` replaces provider SDK touchpoints so E2E runs with stable, test-controlled data. Example: `perps-controller-mixin.ts` for HyperLiquid. See **`tests/docs/CONTROLLER_MOCKING.md`** for when and how to use it. +2. **Command queue / test server** — Tests that need to drive the app (e.g. inject state or commands) can use **`CommandQueueServer`** (`tests/framework/fixtures/CommandQueueServer.ts`). Enable it in the fixture with `useCommandQueueServer: true`. Used by Perps specs (e.g. `tests/smoke/perps/perps-add-funds.spec.ts`, `tests/regression/perps/perps-limit-long-fill.spec.ts`). The app consumes the queue in E2E context. + +**When adding support for a new feature that uses WebSockets or similar:** + +- Follow the **same pattern** as existing features (controller mixin and/or CommandQueueServer). +- Implement under `tests/controller-mocking/mock-config/` or extend the command-queue protocol as needed. +- Add or update **tests/specs** that cover the mock infrastructure and the E2E flow. + +Prefer HTTP mocking whenever the feature’s API is plain HTTP; use controller mocking or the command server only when necessary. diff --git a/.agents/skills/e2e-test/references/page-objects.md b/.agents/skills/e2e-test/references/page-objects.md new file mode 100644 index 00000000000..1efa575d28a --- /dev/null +++ b/.agents/skills/e2e-test/references/page-objects.md @@ -0,0 +1,189 @@ +# Page Objects & Selectors — Reference + +## Page Object Location + +``` +tests/page-objects/ +└── / + ├── MyFeatureView.ts ← main screen PO + ├── MyFeatureList.ts ← list/modal PO + └── MyFeatureDetailsView.ts ← detail screen PO +``` + +One class per screen or significant UI component. + +## Full Page Object Template + +```typescript +// tests/page-objects/Predict/PredictMarketList.ts +import Matchers from '../../framework/Matchers'; +import Gestures from '../../framework/Gestures'; +import Assertions from '../../framework/Assertions'; +import { Utilities } from '../../framework'; +import { PredictMarketListSelectorsIDs } from '../../../app/components/UI/Predict/PredictMarketList.testIds'; + +class PredictMarketList { + // --- Getters (element references, never interact directly in spec) --- + + get container() { + return Matchers.getElementByID(PredictMarketListSelectorsIDs.CONTAINER); + } + + get searchInput() { + return Matchers.getElementByID(PredictMarketListSelectorsIDs.SEARCH_INPUT); + } + + get firstCard() { + return Matchers.getElementByID(PredictMarketListSelectorsIDs.FIRST_CARD); + } + + // --- Action methods --- + + async tapFirstCard(): Promise { + await Gestures.tap(this.firstCard, { + description: 'tap first market card', + }); + } + + async typeSearchQuery(query: string): Promise { + await Gestures.typeText(this.searchInput, query, { + description: `type search query: ${query}`, + }); + } + + // --- Assertion methods --- + + async expectContainerVisible(): Promise { + await Assertions.expectElementToBeVisible(this.container, { + description: 'market list container should be visible', + }); + } + + async expectContainerNotVisible(): Promise { + await Assertions.expectElementToNotBeVisible(this.container, { + description: 'market list container should not be visible', + }); + } + + // --- Retry pattern for flaky interactions --- + + async tapFirstCardWithRetry(): Promise { + await Utilities.executeWithRetry( + async () => { + await Gestures.tap(this.firstCard, { + timeout: 2000, + description: 'tap first card', + }); + await Assertions.expectElementToBeVisible(this.firstCard, { + timeout: 2000, + description: 'first card visible', + }); + }, + { timeout: 30000, description: 'tap first card and verify' }, + ); + } +} + +export default new PredictMarketList(); +``` + +## Selector / TestId Conventions + +### Preferred: Co-locate with component + +```typescript +// app/components/UI/Predict/PredictMarketList.testIds.ts +export const PredictMarketListSelectorsIDs = { + CONTAINER: 'predict-market-list-container', + SEARCH_INPUT: 'predict-market-list-search-input', + FIRST_CARD: 'predict-market-list-first-card', + CARD_TITLE: 'predict-market-list-card-title', +} as const; +``` + +Then use in the component: + +```tsx + +``` + +### Legacy: under tests/selectors/ + +```typescript +// tests/selectors/Predict/PredictMarketList.selectors.ts +export const PredictMarketListSelectorsIDs = { + CONTAINER: 'predict-market-list-container', +} as const; +``` + +Use co-location for all new code. + +### Prefer testID on the component; text/label only when needed + +**Prefer adding a `testID` to the component** so the Page Object can use `Matchers.getElementByID()`. Add the `testID` in the app component and define the constant in the co-located `*.testIds.ts` (e.g. `PredictBalanceSelectorsIDs.WITHDRAW_BUTTON`). This keeps selectors stable and independent of copy/locale. + +Use **text** (`getElementByText`) or **label** (`getElementByLabel`) selectors only when adding a testID is not feasible (e.g. third-party or legacy component you cannot change). In that case, define the string in a SelectorsText object in the same `*.testIds.ts` (e.g. from locale) and use it in the Page Object. + +### Activity / transaction list assertions + +To assert that a transaction appears in the Activity list (e.g. after deposit or withdraw): + +- Use **ActivitiesView** (`tests/page-objects/Transactions/ActivitiesView.ts`): `tapOnPredictionsTab()`, then match the activity row by its type label. +- Activity type labels live in **ActivitiesView.testIds.ts** (`ActivitiesViewSelectorsText`, e.g. `PREDICT_DEPOSIT`, `PREDICT_WITHDRAW`). If your transaction type is missing, add it there and a getter in ActivitiesView (e.g. `get predictWithdraw()`), then use `Assertions.expectElementToBeVisible(ActivitiesView.predictWithdraw, { description: '...' })`. + +## Matchers API + +```typescript +// By testID (most common) +Matchers.getElementByID('my-test-id'); + +// By text +Matchers.getByText('Submit'); + +// By label (accessibility) +Matchers.getElementByLabel('Close button'); +``` + +## Gestures API + +```typescript +// Tap +await Gestures.tap(element, { description: 'tap X' }); + +// Tap with stability check (for animated elements) +await Gestures.tap(element, { + checkStability: true, + description: 'tap animated X', +}); + +// Tap disabled element +await Gestures.tap(element, { + checkEnabled: false, + description: 'tap loading button', +}); + +// Type text +await Gestures.typeText(input, 'hello', { description: 'type hello in input' }); + +// Swipe +await Gestures.swipe(element, 'up', 'slow', 0.5, { description: 'swipe up' }); +``` + +## Assertions API + +```typescript +// Visible +await Assertions.expectElementToBeVisible(element, { description: '...' }); + +// Not visible +await Assertions.expectElementToNotBeVisible(element, { description: '...' }); + +// Text present on screen +await Assertions.expectTextDisplayed('some text', { description: '...' }); + +// With custom timeout +await Assertions.expectElementToBeVisible(element, { + description: '...', + timeout: 10000, +}); +``` diff --git a/.agents/skills/e2e-test/references/running-tests.md b/.agents/skills/e2e-test/references/running-tests.md new file mode 100644 index 00000000000..3a2a2fb615a --- /dev/null +++ b/.agents/skills/e2e-test/references/running-tests.md @@ -0,0 +1,123 @@ +# Running & Debugging E2E Tests — Reference + +## Step 1: Verify the Build Exists + +**Always check before running.** The binary path comes from `.detoxrc.js`: + +```bash +# Check default iOS debug build path +ls ios/build/Build/Products/Debug-iphonesimulator/MetaMask.app 2>/dev/null \ + && echo "✅ Build found — ready to run" \ + || echo "❌ Build missing" + +# If PREBUILT_IOS_APP_PATH is set (CI pre-built binary), check that instead +[ -n "$PREBUILT_IOS_APP_PATH" ] && \ + ls "$PREBUILT_IOS_APP_PATH" 2>/dev/null \ + && echo "✅ Pre-built binary found" \ + || echo "❌ PREBUILT_IOS_APP_PATH set but binary not found at: $PREBUILT_IOS_APP_PATH" +``` + +**If the build is missing**, do **not** run the build yourself. Warn the user that a debug build is required (~20-30 min for iOS) and show them the command so they can run it themselves: + +```bash +# iOS debug build (simulator, no device needed) +yarn test:e2e:ios:debug:build + +# Android debug build (requires emulator) +yarn test:e2e:android:debug:build +``` + +> Prefer **iOS** for local runs: simulator builds need no physical device and tests execute with zero manual interaction. + +## Step 2: Run a Specific Spec + +```bash +# iOS — run one spec file (preferred for local runs) +IS_TEST='true' NODE_OPTIONS='--experimental-vm-modules' \ + detox test -c ios.sim.main \ + --testPathPattern="tests/regression/predict/predict-buy-flow.spec.ts" + +# iOS — run a specific test by name +IS_TEST='true' NODE_OPTIONS='--experimental-vm-modules' \ + detox test -c ios.sim.main \ + --testPathPattern="tests/regression/predict/predict-buy-flow.spec.ts" \ + --testNamePattern="opens market details from market list" + +# Android — run one spec file (requires running emulator) +IS_TEST='true' NODE_OPTIONS='--experimental-vm-modules' \ + detox test -c android.emu.main \ + --testPathPattern="tests/regression/predict/predict-buy-flow.spec.ts" +``` + +## Run All Tests for a Feature + +```bash +IS_TEST='true' NODE_OPTIONS='--experimental-vm-modules' \ + detox test -c ios.sim.main \ + --testPathPattern="tests/regression/predict/" +``` + +## Lint & Type Check (Run Before Every Test Execution) + +```bash +# Lint a specific file +yarn lint tests/regression/predict/predict-buy-flow.spec.ts --fix +yarn lint tests/page-objects/Predict/PredictMarketList.ts --fix + +# Lint all new files together +yarn lint tests/regression/predict/ tests/page-objects/Predict/ --fix + +# TypeScript check (whole project) +yarn lint:tsc +``` + +## Common Failures & Fixes + +| Failure | Cause | Fix | +| ----------------------------- | ----------------------------------------- | --------------------------------------------------------------------- | +| `Error: element not found` | Wrong testID string, element not rendered | Check selector constant, verify `testID` in component | +| `Error: element not enabled` | Button disabled or loading state | Add `checkEnabled: false` to the `Gestures.tap` call | +| `Timeout waiting for element` | Element renders but too slowly | Add logger; check feature flag mock; increase `timeout` in Assertions | +| `Animation/stability error` | UI animating when tap is attempted | Add `checkStability: true` to `Gestures.tap` | +| `Unmocked API request` | Network call not intercepted | Add the URL to `testSpecificMock` | +| `Feature flag not enabled` | Feature hidden by flag | Add `setupRemoteFeatureFlagsMock` in `testSpecificMock` | +| `loginToApp timeout` | Onboarding modal or slow load | Ensure `restartDevice: true` in `withFixtures` | + +## Retry for Flaky Interactions + +Use `Utilities.executeWithRetry` for inherently unstable taps (carousels, animated modals): + +```typescript +import { Utilities } from '../../framework'; + +async tapButtonWithRetry(): Promise { + await Utilities.executeWithRetry( + async () => { + await Gestures.tap(this.button, { timeout: 2000, description: 'tap button' }); + await Assertions.expectElementToBeVisible(this.nextScreen, { + timeout: 2000, + description: 'next screen visible', + }); + }, + { + timeout: 30000, + description: 'tap button and verify navigation', + }, + ); +} +``` + +## Debugging Tips + +1. Add `logger.info(...)` calls in the spec to trace execution progress +2. Check `tests/artifacts/` for screenshots and device logs after a run +3. If the simulator is in an unexpected state: `detox reset-lock-file` then rebuild +4. For animation issues: `await device.disableSynchronization()` before the problematic interaction, `await device.enableSynchronization()` after + +## Iteration Loop + +``` +Fix code → yarn lint --fix → yarn lint:tsc → detox test → read failure → fix → repeat +``` + +Never skip the lint step after making changes. TypeScript errors caught early save debugging time. diff --git a/.agents/skills/e2e-test/references/writing-tests.md b/.agents/skills/e2e-test/references/writing-tests.md new file mode 100644 index 00000000000..a6f1a1d9d68 --- /dev/null +++ b/.agents/skills/e2e-test/references/writing-tests.md @@ -0,0 +1,122 @@ +# Writing E2E Tests — Reference + +## Spec File Location + +| Test Type | Directory | Tag | +| ---------- | ------------------------------------------- | -------------------------------------------------------------------------------------- | +| Smoke | `tests/smoke//.spec.ts` | `SmokeE2E`, `SmokeTrade`, `SmokePredictions`, `SmokePerps`, `SmokeConfirmations`, etc. | +| Regression | `tests/regression//.spec.ts` | `RegressionTrade`, `RegressionWallet`, etc. | + +Import tags from `tests/tags.ts`. Check **`tests/tags.js`** for the full list and descriptions. Use the same tag as **existing specs in that feature folder** (e.g. `tests/smoke/predict/` uses `SmokeTrade`). + +## Minimal Smoke Spec + +```typescript +import { loginToApp } from '../../flows/wallet.flow'; +import { withFixtures } from '../../framework/fixtures/FixtureHelper'; +import { SmokeE2E } from '../../tags'; +import FixtureBuilder from '../../framework/fixtures/FixtureBuilder'; +import MyFeatureView from '../../page-objects/MyFeature/MyFeatureView'; + +describe(SmokeE2E('My Feature'), () => { + it('lands on feature screen after navigation', async () => { + await withFixtures( + { + fixture: new FixtureBuilder().build(), + restartDevice: true, + }, + async () => { + await loginToApp(); + await MyFeatureView.expectScreenVisible(); + }, + ); + }); +}); +``` + +## Regression Spec with API Mocking + +```typescript +import { Mockttp } from 'mockttp'; +import { loginToApp } from '../../flows/wallet.flow'; +import { withFixtures } from '../../framework/fixtures/FixtureHelper'; +import { RegressionTrade } from '../../tags'; +import FixtureBuilder from '../../framework/fixtures/FixtureBuilder'; +import { setupRemoteFeatureFlagsMock } from '../../api-mocking/helpers/remoteFeatureFlagsHelper'; +import { setupMockRequest } from '../../api-mocking/mockHelpers'; +import MyFeatureList from '../../page-objects/MyFeature/MyFeatureList'; +import MyFeatureDetails from '../../page-objects/MyFeature/MyFeatureDetails'; +import { createLogger, LogLevel } from '../../framework/logger'; + +const logger = createLogger({ name: 'MyFeatureSpec', level: LogLevel.INFO }); + +const testSpecificMock = async (mockServer: Mockttp) => { + await setupRemoteFeatureFlagsMock(mockServer, { myFeatureEnabled: true }); + await setupMockRequest(mockServer, { + requestMethod: 'GET', + url: 'https://api.example.com/my-feature/data', + response: { items: [{ id: '1', name: 'Item 1' }] }, + responseCode: 200, + }); +}; + +describe(RegressionTrade('My Feature Details Flow'), () => { + it('opens details from list', async () => { + await withFixtures( + { + fixture: new FixtureBuilder().build(), + restartDevice: true, + testSpecificMock, + }, + async () => { + logger.info('Starting my feature test'); + await loginToApp(); + await MyFeatureList.tapFirstItem(); + await MyFeatureDetails.expectScreenVisible(); + }, + ); + }); +}); +``` + +## FixtureBuilder Patterns + +```typescript +// Basic (just logged-in wallet) +new FixtureBuilder().build(); + +// With popular networks enabled +new FixtureBuilder().withPopularNetworks().build(); + +// With Ganache local network +new FixtureBuilder().withGanacheNetwork().build(); + +// With dapp connected +new FixtureBuilder().withPermissionControllerConnectedToTestDapp().build(); + +// With specific tokens +new FixtureBuilder().withTokensControllerERC20().build(); + +// With contacts +new FixtureBuilder().withAddressBookControllerContactBob().build(); +``` + +## Mandatory Rules + +- Every spec uses `withFixtures` — no plain `beforeAll` / `afterAll` setup +- `restartDevice: true` for most tests (clean state) +- `loginToApp()` always the first call inside the fixture callback +- Test names: descriptive without 'should' prefix +- All gestures and assertions include `description` +- No direct `element(by.id())` calls in specs +- No `TestHelpers.delay()` or `setTimeout()` + +**Synchronization:** Use `device.disableSynchronization()` only when the test hits **timeouts caused by timers or animations** (e.g. confirmation loading, animated modals). Avoid wrapping entire flows by default. Re-enable with `device.enableSynchronization()` after the problematic section. See running-tests.md for the animation tip. + +## Before submitting + +- [ ] `withFixtures` + correct tag (see table above) +- [ ] Only Page Object methods in spec; no direct selectors +- [ ] Every gesture and assertion has a `description` +- [ ] No `TestHelpers.delay()` or `setTimeout()` +- [ ] `yarn lint ` and `yarn lint:tsc` pass diff --git a/.agents/skills/pr-changelog/SKILL.md b/.agents/skills/pr-changelog/SKILL.md new file mode 100644 index 00000000000..19ded5dc64d --- /dev/null +++ b/.agents/skills/pr-changelog/SKILL.md @@ -0,0 +1,56 @@ +--- +name: pr-changelog +description: Generate a CHANGELOG entry line for a pull request from code changes. Use when the user asks to write a changelog entry, fill the changelog section of a PR, or determine if changes are user-facing. +--- + +# PR Changelog + +## Format + +``` +CHANGELOG entry: +``` + +or + +``` +CHANGELOG entry: null +``` + +## Decision + +**User-facing change?** (new feature, bug fix, UI change, behavior change visible to end users) + +- Yes --> write a past-tense summary: `Added...`, `Fixed...`, `Updated...`, `Removed...` +- No --> write `null` (refactors, tests, CI, internal tooling, dev-only changes) + +## Rules + +- CI validates this line exists and is non-empty (`.github/scripts/check-template-and-add-labels.ts`) +- The line must contain `CHANGELOG entry:` followed by a non-empty value (leading whitespace is tolerated by CI) +- Alternative bypass: adding the `no-changelog` label skips the check entirely +- One entry per PR, even if multiple things changed -- summarize the primary user-facing impact + +## Steps + +1. Read the diff: `git diff main...HEAD` +2. Determine if any change is user-facing +3. If yes, write a concise past-tense summary of the primary impact +4. If no, write `null` + +## Examples + +**User-facing:** + +``` +CHANGELOG entry: Added dark mode toggle to settings screen +CHANGELOG entry: Fixed token balance not updating after swap +CHANGELOG entry: Updated network selector to show custom networks first +CHANGELOG entry: Removed deprecated fiat on-ramp provider +``` + +**Not user-facing:** + +``` +CHANGELOG entry: null +``` diff --git a/.agents/skills/pr-codeowners/SKILL.md b/.agents/skills/pr-codeowners/SKILL.md new file mode 100644 index 00000000000..0441a39e2e5 --- /dev/null +++ b/.agents/skills/pr-codeowners/SKILL.md @@ -0,0 +1,52 @@ +--- +name: pr-codeowners +description: Identify code owners for changed files and map them to Slack group handles. Use when the user asks who owns changed files, which teams to tag for review, or to find code owners for a PR. +--- + +# PR Code Owners + +## Steps + +1. Get changed files: + + ```bash + git diff --name-only main...HEAD + ``` + +2. Read `.github/CODEOWNERS` and match each changed file against the patterns to collect unique `@MetaMask/` owners + +3. Map each owner to a Slack group handle using the lookup table below + +4. If an owner is not in the table, fall back to `@metamask-mobile-platform` and warn the user about the unmapped team + +## Code owner to Slack handle lookup + +| CODEOWNERS team | Slack group handle | +| ----------------------- | ---------------------------- | +| perps | mm-perps-engineering-team | +| confirmations | metamask-confirmations-team | +| metamask-earn | earn-dev-team | +| mobile-core-ux | mobile-core-ux | +| accounts-engineers | accounts-team-devs | +| swaps-engineers | swaps-engineers | +| metamask-assets | assets-dev-team | +| card | card-dev-team | +| notifications | notifications-dev-team | +| mobile-platform | metamask-mobile-platform | +| web3auth | onboarding-dev | +| wallet-integrations | wallet-integrations-team | +| wallet-api-platform | wallet-integrations-team | +| ramp | ramp-team | +| predict | predict-team | +| rewards | rewards-team | +| design-system-engineers | metamask-design-system-team | +| core-platform | core-platform-team | +| supply-chain | mm-supply-chain-reviewers | +| mobile-admins | metamask-mobile-platform | +| transactions | mm-transactions-stx-core-dev | +| delegation | delegators | +| qa | metamask-qa-team | + +## Output + +List of unique `{ team, slackHandle }` pairs for all code owners of the changed files. diff --git a/.agents/skills/pr-create/SKILL.md b/.agents/skills/pr-create/SKILL.md new file mode 100644 index 00000000000..ba62b309320 --- /dev/null +++ b/.agents/skills/pr-create/SKILL.md @@ -0,0 +1,98 @@ +--- +name: pr-create +description: Create a GitHub pull request from the current branch. Validates preconditions, generates title and description, and opens the PR as draft. Use when the user asks to create a PR, open a pull request, or submit changes for review. +--- + +# PR Create + +## Workflow + +### 1. Precondition checks + +```bash +git rev-parse --abbrev-ref HEAD +git status --porcelain +git rev-parse --verify origin/ +``` + +- On `main` --> abort: "Cannot create a PR from main" +- Dirty working tree --> abort: "Commit all changes before creating a PR" +- Branch not on origin --> use the `AskQuestion` tool to ask whether to push, with options "Yes, push" and "No, abort" (default: **No, abort**). Never push without explicit user consent. + +### 2. Generate a PR description + +Generate a PR description for the current branch (see `pr-description` skill if available). + +### 3. Create the PR + identify code owners (in parallel) + +Run these two steps concurrently since they are independent: + +**3a. Create the PR** + +**Prefer `gh` CLI:** + +```bash +gh pr create \ + --title "" \ + --body "" \ + --base main \ + --assignee @me \ + --draft +``` + +**Fallback to GitHub MCP** (`create_pull_request` tool from `user-github` server): + +```json +{ + "server": "user-github", + "toolName": "create_pull_request", + "arguments": { + "owner": "MetaMask", + "repo": "metamask-mobile", + "title": "", + "body": "", + "head": "", + "base": "main", + "draft": true + } +} +``` + +**3b. Identify code owners** + +Identify code owners and their Slack handles for the changed files (see `pr-codeowners` skill if available). This only depends on the diff, not the PR itself. + +### 4. Output + +- Print the PR URL +- Provide a Slack review request message in a fenced code block for easy copy + +If code owners were found: + +``` +PR ready for review: + + +cc @ @ +``` + +If no code owners (or only the author's own team): + +``` +PR ready for review: + + +``` + +Tell the user they can paste it in `#metamask-mobile-dev` in the "Mobile PRs that need review" thread of the day. + +### 5. Offer to add PR to review queue + +Use the `AskQuestion` tool to ask whether to add the PR to the [PR review queue](https://github.com/orgs/MetaMask/projects/64/views/1), with options "Yes" and "No" (default: **Yes**). If yes, invoke the `pr-review-queue` skill if available. + +## Rules + +- Always create as **draft** per repo guidelines ("Submit as Draft initially for CI") +- Always assign to `@me` +- Target `main` branch +- Team label is handled automatically by CI (`add-team-label.yml`) on PR open diff --git a/.agents/skills/pr-description/SKILL.md b/.agents/skills/pr-description/SKILL.md new file mode 100644 index 00000000000..2664c9d2bad --- /dev/null +++ b/.agents/skills/pr-description/SKILL.md @@ -0,0 +1,68 @@ +--- +name: pr-description +description: Generate a complete pull request description following the MetaMask Mobile PR template. Use when the user asks to generate a PR description, fill the PR template, or create a pull request body. +--- + +# PR Description + +## Workflow + +1. **Collect context** + + ```bash + git rev-parse --abbrev-ref HEAD + git diff main...HEAD + git log main..HEAD --oneline + ``` + +2. **Generate a PR title** in conventional commit format (see `pr-title` skill if available) + +3. **Find related GitHub issues** from branch name, commit messages, or keyword search (see `pr-issue-search` skill if available) + +4. **Write description** -- analyze the diff to answer: (1) what is the reason for the change? (2) what is the improvement/solution? + +5. **Generate a changelog entry** for the PR (see `pr-changelog` skill if available) + +6. **Generate Gherkin manual testing steps** for the changed features (see `pr-manual-testing` skill if available) + +7. **Verify author checklist items** -- run the `pr-readiness-check` skill (if available) to warn about missing tests, missing JSDoc, or guideline violations. Do not block — continue generating the description regardless of findings. + +8. **Assemble output** -- read `.github/pull-request-template.md` for the full template body, then fill all sections + +## Template Sections + +CI validates that all 7 section titles are present **exactly** as written below (`.github/scripts/shared/template.ts`). Missing or altered titles cause the `invalid-pull-request-template` label. + +| Section title (exact string) | Guidance | +| ------------------------------------- | --------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | +| `## **Description**` | Concise what/why. Keep HTML comments from template as-is. | +| `## **Changelog**` | `CHANGELOG entry: ` or `CHANGELOG entry: null`. Line must exist and be non-empty -- CI enforces this. | +| `## **Related issues**` | `Fixes: #NUMBER` or `Refs: #NUMBER`. Leave `Fixes:` empty if none found. | +| `## **Manual testing steps**` | Gherkin code block. If no useful manual test exists (e.g. automation-only, unit tests suffice), write `N/A`. | +| `## **Screenshots/Recordings**` | Keep Before/After subsections. Write `N/A` in each subsection when not applicable instead of HTML comments. | +| `## **Pre-merge author checklist**` | Include all checklist items from the template. **Check all boxes** (`- [x]`): checking means the author actively considered the item and takes responsibility, even if it doesn't apply to this PR. | +| `## **Pre-merge reviewer checklist**` | Include all checklist items from the template. Leave boxes unchecked — these are for the reviewer. | + +## Output + +Write the result to `.agent/[branch-name].PR-desc.md`. Create the `.agent/` directory if it does not exist. Sanitize the branch name first by replacing `/` with `-` so names like `feat/MCWP-392` become `feat-MCWP-392` (avoids creating nested directories). + +Structure: + +```markdown +# PR Title + + + +--- + + +``` + +Preserve all HTML comments from the original template as guidance for the PR author. + +Append at the very end of the file: + +```markdown + +``` diff --git a/.agents/skills/pr-issue-search/SKILL.md b/.agents/skills/pr-issue-search/SKILL.md new file mode 100644 index 00000000000..4b6c4453ee5 --- /dev/null +++ b/.agents/skills/pr-issue-search/SKILL.md @@ -0,0 +1,103 @@ +--- +name: pr-issue-search +description: Find related GitHub issues for a pull request by extracting from branch name, commit messages, or searching GitHub. Use when the user asks to find related issues, link issues to a PR, or search for GitHub issues to reference. +--- + +# PR Issue Search + +## Strategy + +Follow these steps in order. Stop as soon as you have issue numbers. + +### Step 1: Extract from branch name + +```bash +git rev-parse --abbrev-ref HEAD +``` + +Branch naming conventions (check both): + +**GitHub issue number**: `/_` + +- `fix/1234_wallet-connection-issue` --> `#1234` +- `feat/5678_add-nft-gallery` --> `#5678` + +**Jira ticket ID**: `/_` + +- `feat/MCWP-392_pr_desc_skills` --> Jira ticket `MCWP-392` +- `fix/MOB-1234_fix-crash` --> Jira ticket `MOB-1234` + +Pattern: after the `/` prefix, look for either a bare number (`\d+`) for GitHub issues or an alphanumeric project key (`[A-Z]+-\d+`) for Jira tickets. + +- `chore/update-linting-config` --> no issue or ticket, continue to Step 2 + +### Step 2: Extract from commit messages + +```bash +git log main..HEAD --oneline +``` + +Look for `#NUMBER` references (GitHub issues) and `[A-Z]+-\d+` patterns (Jira tickets) in commit subjects. Collect all unique references. + +### Step 3: Search by keywords + +Only if Steps 1-2 found no issue numbers. + +Build keywords from the branch name segments and commit subjects (strip type prefix, split on hyphens/underscores). + +**Prefer `gh` CLI** (no permission prompt required): + +```bash +gh search issues --repo MetaMask/metamask-mobile "" --limit 5 +``` + +**Fallback to GitHub MCP** if `gh` is unavailable: + +Use the `search_issues` tool from the `user-github` MCP server: + +```json +{ + "server": "user-github", + "toolName": "search_issues", + "arguments": { + "query": "", + "owner": "MetaMask", + "repo": "metamask-mobile", + "perPage": 5 + } +} +``` + +Review results and pick the most relevant issue(s). + +## Output Format + +- `Fixes: #NUMBER` -- use when the PR fully resolves the issue (closes on merge) +- `Refs: #NUMBER` -- use when the PR partially addresses or is related to the issue + +If multiple issues are found: + +``` +Fixes: #1234 +Refs: #5678 +``` + +If no issues are found, leave the section as: + +``` +Fixes: +``` + +## Examples + +**Branch with GitHub issue number:** +Branch `fix/9012_token-balance-stale` --> `Fixes: #9012` + +**Branch with Jira ticket ID:** +Branch `feat/MCWP-392_pr_desc_skills` --> `Refs: MCWP-392` (Jira tickets go in `Refs:`, not `Fixes:`, since GitHub cannot auto-close Jira tickets) + +**Commit with reference:** +Commit message `implement caching for token prices (#3456)` --> `Refs: #3456` + +**Keyword search:** +Branch `feat/bridge-fee-estimation` --> search "bridge fee estimation" --> find issue #7890 "Bridge: show estimated fees before confirmation" --> `Fixes: #7890` diff --git a/.agents/skills/pr-manual-testing/SKILL.md b/.agents/skills/pr-manual-testing/SKILL.md new file mode 100644 index 00000000000..ce69d325472 --- /dev/null +++ b/.agents/skills/pr-manual-testing/SKILL.md @@ -0,0 +1,108 @@ +--- +name: pr-manual-testing +description: Generate Gherkin-format manual testing steps from code changes for pull request descriptions. Use when the user asks for manual testing steps, Gherkin test scenarios, or QA steps for a PR. +--- + +# PR Manual Testing + +## Format + +Write scenarios inside a fenced code block with `gherkin` language tag. + +**Keywords**: `Feature`, `Background`, `Scenario`, `Given`, `When`, `Then`, `And` + +- `Feature` -- name of the feature being tested +- `Background` -- shared preconditions across all scenarios (optional, use when multiple scenarios share setup) +- `Scenario` -- one distinct user flow to verify +- `Given` -- initial state / preconditions +- `When` -- user action +- `Then` -- expected outcome +- `And` -- continuation of the previous keyword + +Use **data tables** (`| col | col |`) when verifying multiple inputs or list entries. + +## Steps + +1. Read the diff: `git diff main...HEAD` +2. Identify the changed features and user-facing behaviors +3. Write scenarios covering the primary happy path and critical edge cases +4. Use multiple `Scenario` blocks when changes affect distinct behaviors + +## Template + +```gherkin +Feature: [feature name derived from changes] + + Background: + Given I am logged into MetaMask Mobile + + Scenario: [describe the user flow being verified] + Given [initial app state] + + When user [performs action] + Then [expected outcome] +``` + +## Examples + +**Simple single scenario:** + +```gherkin +Feature: Token balance refresh + + Scenario: user pulls to refresh token balances + Given I am on the Wallet home screen + And I have tokens in my portfolio + + When user pulls down on the token list + Then the token balances should update + And a loading indicator should appear briefly +``` + +**Multiple scenarios with background:** + +```gherkin +Feature: Network selector migration to design system + + Background: + Given I am logged into MetaMask Mobile + And I have multiple networks configured + + Scenario: user switches network from wallet screen + Given I am on the Wallet home screen + + When user taps the network selector + Then I should see the network list bottom sheet + And the current network should be highlighted + + When user selects "Linea" + Then the network should switch to "Linea" + And the wallet should display Linea balances + + Scenario: user dismisses network selector + Given I am on the Wallet home screen + + When user taps the network selector + And user taps outside the bottom sheet + Then the network selector should close + And the network should remain unchanged +``` + +**Scenario with data table:** + +```gherkin +Feature: Address book validation + + Scenario: user enters invalid addresses + Given I am on the Send screen + + When user enters the following invalid addresses: + | Input | Reason | + | 0x123 | Too short | + | not-an-address | Invalid format | + Then the "Next" button should remain disabled +``` + +## Reference + +For richer Gherkin patterns (tags, complex data tables, network-specific scenarios), see `app/features/SampleFeature/e2e/sample-scenarios.feature`. diff --git a/.agents/skills/pr-readiness-check/SKILL.md b/.agents/skills/pr-readiness-check/SKILL.md new file mode 100644 index 00000000000..d08e074d504 --- /dev/null +++ b/.agents/skills/pr-readiness-check/SKILL.md @@ -0,0 +1,55 @@ +--- +name: pr-readiness-check +description: Check branch changes for common PR readiness issues (missing tests, missing JSDoc, guideline violations). Use when the user asks to verify changes before opening a PR, check code quality, or audit a branch for missing items. +--- + +# PR Readiness Check + +Scan the current branch diff for common issues that could be flagged during PR review. This is a **non-blocking** check — always report findings as warnings and let the user decide what to act on. + +## Steps + +1. **Collect the diff** + + ```bash + git diff main...HEAD --name-only + git diff main...HEAD + ``` + +2. **Check for missing tests** + + For each new or modified source file with non-trivial logic changes (not just config, docs, or styles), check whether a corresponding test file was added or updated. + + Heuristic: a source file `Foo.ts(x)` should have a matching `Foo.test.ts(x)` (or `Foo.view.test.tsx` for components). Warn if: + - New exported functions/components have no corresponding test file changes + - Existing test files were not updated despite significant logic changes in the source + +3. **Check for missing JSDoc** + + Scan new exported functions, types, and components in the diff. Warn if any lack JSDoc comments. + +4. **Check for guideline violations** + + Look for obvious violations of `.github/guidelines/CODING_GUIDELINES.md` and `.cursor/rules/` patterns in the changed lines: + - `any` type usage in TypeScript + - `StyleSheet.create()` in new code + - Raw `View` or `Text` imports from `react-native` instead of design system `Box`/`Text` + - `import tw from 'twrnc'` instead of `useTailwind()` hook + - `npx` usage in scripts + +## Output + +Print each finding as a warning line: + +``` +⚠ No tests detected for new logic in `app/core/Foo.ts` +⚠ Missing JSDoc on exported function `calculateFee` in `app/util/fees.ts` +⚠ `any` type used in `app/components/Bar.tsx:42` +⚠ `StyleSheet.create()` found in new file `app/components/Baz/Baz.tsx` +``` + +If no issues found, confirm: + +``` +✅ No readiness issues detected +``` diff --git a/.agents/skills/pr-review-queue/SKILL.md b/.agents/skills/pr-review-queue/SKILL.md new file mode 100644 index 00000000000..9bc5bf776b7 --- /dev/null +++ b/.agents/skills/pr-review-queue/SKILL.md @@ -0,0 +1,96 @@ +--- +name: pr-review-queue +description: Add a pull request to the MetaMask PR review queue project board. Use when the user asks to add a PR to the review queue, submit a PR for review tracking, or add to the MetaMask project board. +--- + +# PR Review Queue + +Add a pull request to the [MetaMask PR review queue](https://github.com/orgs/MetaMask/projects/64/views/1) project board. + +## Input + +- **PR URL or PR number** (required) +- **Priority** (ask user, default: "Priority 3" — see step 1 below for fetching options dynamically) + +## Method + +### 1. Fetch project fields (Priority options + field IDs) + +Priority options can change — always fetch them dynamically: + +```bash +gh project field-list 64 --owner MetaMask --format json +``` + +From the JSON response: + +- Find the field with `"name": "Priority"` → extract its `id` (the Priority field ID) and its `options` array (each with `id` and `name`). +- Find the field with `"name": "Comment"` → extract its `id` (the Comment field ID). + +Use the `AskQuestion` tool to present the Priority options and ask the user to pick one. Default to the option whose name contains "Priority 3" if the user does not specify. + +### 2. Add PR to the project + +```bash +gh project item-add 64 --owner MetaMask --url --format json --jq '.id' +``` + +This returns the **item node ID** needed for field updates. + +### 3. Get project node ID + +```bash +gh project view 64 --owner MetaMask --format json --jq '.id' +``` + +### 4. Set Priority field + +Use the Priority field ID and selected option ID obtained in step 1. + +```bash +gh api graphql -f query=' + mutation($projectId: ID!, $itemId: ID!, $fieldId: ID!, $optionId: String!) { + updateProjectV2ItemFieldValue(input: { + projectId: $projectId, + itemId: $itemId, + fieldId: $fieldId, + value: { singleSelectOptionId: $optionId } + }) { projectV2Item { id } } + } +' -f projectId="" \ + -f itemId="" \ + -f fieldId="" \ + -f optionId="" +``` + +### 5. Set Comment field with current date + +Use the Comment field ID obtained in step 1. + +Automatically compute today's date in short month + day format (e.g. "Feb 21", "Mar 4", "Jan 10") — do NOT ask the user for this value. + +```bash +DATE_COMMENT=$(date +"%b %-d") + +gh api graphql -f query=' + mutation($projectId: ID!, $itemId: ID!, $fieldId: ID!, $comment: String!) { + updateProjectV2ItemFieldValue(input: { + projectId: $projectId, + itemId: $itemId, + fieldId: $fieldId, + value: { text: $comment } + }) { projectV2Item { id } } + } +' -f projectId="" \ + -f itemId="" \ + -f fieldId="" \ + -f comment="$DATE_COMMENT" +``` + +## Output + +Confirm the PR was added to the review queue with: + +- A link to the project board +- The priority that was set +- The date comment that was added diff --git a/.agents/skills/pr-title/SKILL.md b/.agents/skills/pr-title/SKILL.md new file mode 100644 index 00000000000..dbc61086c9a --- /dev/null +++ b/.agents/skills/pr-title/SKILL.md @@ -0,0 +1,72 @@ +--- +name: pr-title +description: Generate a conventional commit PR title from git diff analysis. Use when the user asks to generate a PR title, write a pull request title, or create a conventional commit title for a branch. +--- + +# PR Title + +## Format + +``` +[optional scope]: +``` + +- Max 72 characters total +- Imperative mood ("add", not "added" or "adds") +- No ticket/issue numbers (those belong in the PR description) +- Lowercase type and scope + +## Types + +| Type | When to use | +| ---------- | -------------------------------------------- | +| `feat` | New feature or enhancement | +| `fix` | Bug fix | +| `refactor` | Code restructuring without behavior change | +| `chore` | Maintenance (dependencies, configs, scripts) | +| `test` | Adding or updating tests | +| `docs` | Documentation changes | +| `perf` | Performance improvements | +| `style` | Code style/formatting (no logic change) | +| `ci` | CI/CD pipeline changes | +| `build` | Build system or external dependency changes | +| `revert` | Reverting a previous commit | + +## Scope + +Optional. Infer from the primary folder or feature area being modified. + +Infer the scope from the primary feature area or directory being modified. Any meaningful area in the repo is a valid scope. + +**Common scopes** (non-exhaustive): `analytics`, `transactions`, `wallet`, `ui`, `network`, `settings`, `permissions`, `tokens`, `nfts`, `swaps`, `bridge`, `staking`, `onboarding`, `confirmations`, `agents`, `e2e`, `deps`, `ramp`, `earn`, `perps`, `predict`, `notifications`, `accounts` + +**Avoid**: Generic scopes like `app`, `src`, `components` that don't convey meaning. + +**When to omit**: If changes span multiple unrelated areas, omit scope entirely. + +## Steps + +1. Get the branch name: `git rev-parse --abbrev-ref HEAD` +2. Get the full diff: `git diff main...HEAD` +3. Determine the **type** from the nature of the changes +4. Infer a **scope** from the primary folder/feature being modified (omit if ambiguous) +5. Write a concise **description** in imperative mood + +## Examples + +``` +feat(predict): add market details view +fix(transactions): resolve memory leak in controller +refactor(analytics): migrate rewards tracking to useAnalytics hook +chore(deps): update @metamask/controller-utils to v5.0.0 +test(onboarding): add e2e spec for SRP import flow +docs(staking): update pooled staking integration guide +perf(wallet): lazy-load token list on scroll +style(ui): apply design system tokens to network selector +ci: add conventional commit validation to PR checks +revert: undo NFT gallery feature flag removal +``` + +## CI Validation + +PR titles are validated by the `amannn/action-semantic-pull-request` GitHub Action (`.github/workflows/pr-title-linter.yml`). Titles that don't follow Conventional Commits format will fail CI. diff --git a/.claude/skills/e2e-test/SKILL.md b/.claude/skills/e2e-test/SKILL.md new file mode 100644 index 00000000000..f6b74db203e --- /dev/null +++ b/.claude/skills/e2e-test/SKILL.md @@ -0,0 +1,11 @@ +--- +name: e2e-test +summary: + Add and fix Detox E2E tests (smoke and regression) for MetaMask Mobile using + withFixtures, Page Objects, and tests/framework. Use when creating a new spec, + fixing a failing E2E test, or adding page objects and selectors. +--- + +**BLOCKING REQUIREMENT**: When the user asks anything related to E2E tests, Detox, smoke/regression specs, writing or fixing \*.spec.ts in tests/smoke or tests/regression, page objects, or any mention of the E2E test framework — invoke this skill via the Skill tool **immediately**, before reading any files or exploring the codebase. + +Canonical skill (full content, decision tree, references): [.agents/skills/e2e-test/SKILL.md](../../.agents/skills/e2e-test/SKILL.md). diff --git a/.cursor/rules/ui-development-guidelines.mdc b/.cursor/rules/ui-development-guidelines.mdc index d63eb2452d2..ee798e3f326 100644 --- a/.cursor/rules/ui-development-guidelines.mdc +++ b/.cursor/rules/ui-development-guidelines.mdc @@ -237,6 +237,19 @@ Always use semantic color tokens: | `flexDirection: 'row'` | `flexDirection={BoxFlexDirection.Row}` | | Manual padding/margin | `twClassName="p-4 m-2"` | +## Platform-Specific Gotchas + +### ScrollView Inside BottomSheet +When using a `ScrollView` inside a `BottomSheet`, you **MUST** import `ScrollView` from `react-native-gesture-handler`, not from `react-native`. The standard React Native `ScrollView` will not scroll on Android within a gesture-handler-managed `BottomSheet`. + +```tsx +// ✅ CORRECT - works on both iOS and Android +import { ScrollView } from 'react-native-gesture-handler'; + +// ❌ WRONG - will not scroll on Android inside BottomSheet +import { ScrollView } from 'react-native'; +``` + ## Legacy Code Migration Guidelines ### Identifying Legacy Patterns @@ -314,6 +327,7 @@ const tw = useTailwind(); - [ ] No separate `.styles.ts` files for new components - [ ] Component props used before `twClassName` for layout - [ ] Interactive styles use `tw.style()` with state functions +- [ ] `ScrollView` inside `BottomSheet` imported from `react-native-gesture-handler` (not `react-native`) ### When You See These Patterns, IMMEDIATELY Suggest Alternatives: - Any `import tw from 'twrnc'` → `import { useTailwind } from '@metamask/design-system-twrnc-preset'` diff --git a/.github/CODEOWNERS b/.github/CODEOWNERS index 4129f2e4708..68355fa4931 100644 --- a/.github/CODEOWNERS +++ b/.github/CODEOWNERS @@ -246,8 +246,23 @@ app/core/Engine/messengers/seedless-onboarding-controller-messenger @MetaMask/we app/core/Engine/controllers/seedless-onboarding-controller @MetaMask/web3auth app/core/OAuthService @MetaMask/web3auth app/components/Views/Onboarding @MetaMask/web3auth -app/components/Views/OnboardingCarousel @MetaMask/web3auth +app/components/Views/OnboardingSheet @MetaMask/web3auth app/components/Views/OnboardingSuccess @MetaMask/web3auth +app/components/Views/ChoosePassword @MetaMask/web3auth +app/components/Views/ManualBackupStep1 @MetaMask/web3auth +app/components/Views/ManualBackupStep2 @MetaMask/web3auth +app/components/Views/ManualBackupStep3 @MetaMask/web3auth +app/components/Views/AccountBackupStep1 @MetaMask/web3auth +app/components/Views/AccountBackupStep1B @MetaMask/web3auth +app/components/Views/ImportFromSecretRecoveryPhrase @MetaMask/web3auth +app/components/Views/ImportNewSecretRecoveryPhrase @MetaMask/web3auth +app/components/Views/SelectSRP @MetaMask/web3auth +app/components/Views/SrpInput @MetaMask/web3auth +app/components/Views/RestoreWallet @MetaMask/web3auth +app/components/Views/WalletCreationError @MetaMask/web3auth +app/components/Views/OAuthRehydration @MetaMask/web3auth +app/components/Views/SocialLoginIosUser @MetaMask/web3auth +app/components/Views/RevealPrivateCredential @MetaMask/web3auth app/reducers/onboarding @MetaMask/web3auth # Delegation team diff --git a/.github/scripts/collect-qa-stats.mjs b/.github/scripts/collect-qa-stats.mjs index ced016211bc..58239909e11 100644 --- a/.github/scripts/collect-qa-stats.mjs +++ b/.github/scripts/collect-qa-stats.mjs @@ -1,25 +1,32 @@ #!/usr/bin/env node /** * - * Downloads pre-aggregated QA stats artifacts from the triggering CI run via the - * GitHub API and writes a qa-stats.json file for consumption by downstream workflows. + * Collects QA metrics from a CI run and writes qa-stats.json, key: value format. + * Metrics that could not be collected (missing artifacts, tests did not run) + * are omitted from the output — they will never appear as zero. * * Required env vars: * GITHUB_TOKEN — GitHub Actions token for API access - * WORKFLOW_RUN_ID — ID of the CI run that produced the artifacts - * - * Example of output format of qa-stats.json: + * WORKFLOW_RUN_ID — ID of the main CI run that produced tests artifacts + * + * How to add a new metric: + * 1. Add a collector function that returns a plain object + * 2. Register it in the collectors array in main() + * + * The only rule: never rename existing keys. The DB key is (project, run_id, namespace, metric_key). + * Renaming a key in the JSON creates a new series in the DB while the old name stops getting new data, + * which breaks the Grafana time series continuity. Adding and removing keys is fine. + * + * Example output: * { - * "component_view_tests_count": 34, - * "unit_test_count": 679, + * "component_view": { "tests_count": 94 }, + * "unit": { "tests_count": 41957 }, + * "e2e": { "tests_count": 420, "main_tests_count": 276, "confirmations_tests_count": 62, "flask_tests_count": 144 }, + * "performance": { "tests_count": 21, "login_tests_count": 11, "onboarding_tests_count": 4, "mm_connect_tests_count": 6 } * } - * - * How to add a new metric: - * 1. Add a collector function below (see existing example) - * 2. Call it in main() and assign the result to stats */ -import { readFile, writeFile, mkdir } from 'fs/promises'; +import { readFile, writeFile, mkdir, readdir } from 'fs/promises'; import { execSync } from 'child_process'; import { join } from 'path'; @@ -37,7 +44,7 @@ if (!GITHUB_TOKEN) throw new Error('Missing required GITHUB_TOKEN env var'); let _artifactList = null; /** - * Fetches (and caches) the list of artifacts for the triggering CI run. + * Fetches (and caches) the list of artifact names for the triggering CI run. * First call fetches and stores, every subsequent call returns the cached value. * * @returns {Promise} @@ -124,18 +131,249 @@ async function downloadArtifact(artifactName) { // Collectors — one async function per metric source // --------------------------------------------------------------------------- +/** + * Extracts a feature folder name from a Jest test file path. + * + * Priority: + * app/components/UI// → (e.g. Bridge, Perps, Earn) + * app/components/Views// → (e.g. Wallet, AssetDetails) + * app// → (e.g. util, core, hooks) + * + * @param {string} testFilePath + * @returns {string} + */ +function getFeatureFolder(testFilePath) { + const normalize = (s) => s.toLowerCase().replace(/-/g, '_'); + // app/components/UI// → + const uiMatch = testFilePath.match(/app\/components\/UI\/([^/]+)/); + if (uiMatch) return normalize(uiMatch[1]); + // app/components/Views// → + const viewsMatch = testFilePath.match(/app\/components\/Views\/([^/]+)/); + if (viewsMatch) return normalize(viewsMatch[1]); + // app/components// → components_ (e.g. components_snaps, components_hooks) + const componentsMatch = testFilePath.match(/app\/components\/([^/]+)/); + if (componentsMatch) return `components_${normalize(componentsMatch[1])}`; + // app// → (e.g. core, util, store, selectors, component_library) + const appMatch = testFilePath.match(/app\/([^/]+)/); + return appMatch ? normalize(appMatch[1]) : 'other'; +} + +/** + * Downloads all shard artifacts matching artifactPattern, reads the + * jest-results.json from each, and returns test counts grouped by feature folder. + * + * Folders whose total count is below minFolderCount are merged into `other` + * to reduce noise (useful for unit tests which have hundreds of component-level folders). + * + * @param {RegExp} artifactPattern + * @param {string} label — used in log messages + * @param {number} [minFolderCount=0] — folders with fewer tests are bucketed into `other` + * @returns {Promise>} + */ +async function collectShardCounts(artifactPattern, label, minFolderCount = 0) { + const artifacts = await getArtifactList(); + const shardArtifacts = artifacts.filter((a) => artifactPattern.test(a.name)); + console.log(`[${label}] found ${shardArtifacts.length} shard artifact(s)`); + + if (shardArtifacts.length === 0) return {}; + + const folderCounts = {}; + let total = 0; + + for (const artifact of shardArtifacts) { + const destDir = await downloadArtifact(artifact.name); + const raw = await readFile(join(destDir, 'jest-results.json'), 'utf8'); + const { testResults } = JSON.parse(raw); + // Jest --json CLI output uses `name` for the file path and `assertionResults` + // for per-test outcomes (not numPassingTests/numFailingTests which are top-level aggregates). + for (const { name, assertionResults } of testResults) { + const passed = assertionResults.filter((r) => r.status === 'passed').length; + const failed = assertionResults.filter((r) => r.status === 'failed').length; + const count = passed + failed; + total += count; + const folder = getFeatureFolder(name); + folderCounts[folder] = (folderCounts[folder] ?? 0) + count; + } + } + + console.log(`[${label}] total: ${total}`); + const result = { tests_count: total }; + for (const [folder, count] of Object.entries(folderCounts)) { + if (minFolderCount > 0 && count < minFolderCount) { + result.other_tests_count = (result.other_tests_count ?? 0) + count; + } else { + result[`${folder}_tests_count`] = count; + } + } + return result; +} + async function collectComponentViewTestCount() { - const destDir = await downloadArtifact('cv-test-stats'); - const raw = await readFile(join(destDir, 'cv-test-stats.json'), 'utf8'); - const data = JSON.parse(raw); - return data.component_view_test_number; + console.log('[component-view] collecting per-suite counts from shard artifacts...'); + return collectShardCounts(/^coverage-cv-\d+$/, 'component-view'); } async function collectUnitTestCount() { - const destDir = await downloadArtifact('unit-test-stats'); - const raw = await readFile(join(destDir, 'unit-test-stats.json'), 'utf8'); - const data = JSON.parse(raw); - return data.unit_test_number; + console.log('[unit] collecting per-suite counts from shard artifacts...'); + // minFolderCount=200: buckets individual component-level folders into `other`, + // keeping only meaningful team-level categories (bridge, perps, confirmations, etc.) + return collectShardCounts(/^coverage-unit-\d+$/, 'unit', 200); +} + +/** + * Parses a JUnit artifact name into canonical E2E dimensions. + * + * Returns null for non-E2E artifacts. + * + * @param {string} artifactName + * @returns {{ channel: 'main'|'flask', platform: 'android'|'ios', suiteTag: string|null } | null} + */ +function getE2EArtifactDimensions(artifactName) { + const match = artifactName.match(/^test-e2e-(.+)-junit-results$/); + if (!match) return null; + + let jobName = match[1]; + // Strip the default 'main-' prefix applied by run-e2e-workflow.yml + if (jobName.startsWith('main-')) { + jobName = jobName.slice('main-'.length); + } + + const flaskMatch = jobName.match(/^flask-(android|ios)-smoke-\d+$/); + if (flaskMatch) { + return { channel: 'flask', platform: flaskMatch[1], suiteTag: null }; + } + + const mainMatch = jobName.match(/^(.+)-(android|ios)-smoke-\d+$/); + if (!mainMatch) return null; + + return { + channel: 'main', + platform: mainMatch[2], + suiteTag: mainMatch[1].replace(/-/g, '_'), + }; +} + +function getNumericAttribute(tag, name) { + const match = tag.match(new RegExp(`${name}="(\\d+)"`)); + return match ? Number(match[1]) : 0; +} + +function countExecutedTestsFromJUnitXml(rawXml) { + const suiteTags = rawXml.match(/]*>/g) ?? []; + return suiteTags.reduce((total, suiteTag) => { + const tests = getNumericAttribute(suiteTag, 'tests'); + const skipped = getNumericAttribute(suiteTag, 'skipped'); + return total + Math.max(0, tests - skipped); + }, 0); +} + +// Collects all E2E test counts from JUnit artifacts. +async function collectE2ECounts() { + const artifacts = await getArtifactList(); + + // Per-platform counts for health signalling + const platformCounts = { + main: { android: 0, ios: 0 }, + flask: { android: 0, ios: 0 }, + }; + // Per-suite counts from Android only (canonical unique count) + const suiteCounts = {}; + + const e2eArtifacts = artifacts.filter((a) => getE2EArtifactDimensions(a.name)); + console.log(`[e2e] found ${e2eArtifacts.length} JUnit artifact(s)`); + + if (e2eArtifacts.length === 0) { + console.log('[e2e] no JUnit artifacts found — E2E tests did not run, skipping e2e metrics'); + return {}; + } + + for (const artifact of e2eArtifacts) { + const { channel, platform, suiteTag } = getE2EArtifactDimensions(artifact.name); + + const destDir = await downloadArtifact(artifact.name); + const junitXml = await readFile(join(destDir, 'junit.xml'), 'utf8'); + const count = countExecutedTestsFromJUnitXml(junitXml); + console.log(`[e2e] ${artifact.name}: ${count} test(s)`); + + platformCounts[channel][platform] += count; + + // Per-suite breakdown uses Android only to represent unique test count + if (channel === 'main' && platform === 'android' && suiteTag) { + suiteCounts[suiteTag] = (suiteCounts[suiteTag] ?? 0) + count; + } + } + + const androidMain = platformCounts.main.android; + const iosMain = platformCounts.main.ios; + const androidFlask = platformCounts.flask.android; + const iosFlask = platformCounts.flask.ios; + + const result = {}; + + // Canonical unique counts (Android as source of truth — same tests run on iOS) + // A missing key means that channel did not run; present-but-zero means it ran and found nothing. + if (androidMain > 0 || iosMain > 0) { + result.main_tests_count = androidMain; // unique count + result.main_android_tests_count = androidMain; // platform health signal + result.main_ios_tests_count = iosMain; // drops to 0 if iOS infrastructure is broken + } + if (androidFlask > 0 || iosFlask > 0) { + result.flask_tests_count = androidFlask; // unique count + result.flask_android_tests_count = androidFlask; + result.flask_ios_tests_count = iosFlask; + } + result.tests_count = androidMain + androidFlask; + + for (const [tag, count] of Object.entries(suiteCounts)) { + result[`${tag}_tests_count`] = count; + } + + return result; +} + +/** + * Counts executed performance scenarios by scanning *.spec.js files + * under tests/performance/ and counting non-skipped test() calls. + * + * The top-level subdirectory (login, onboarding, mm-connect) determines the + * category for per-category metrics. + */ +async function collectPerformanceTestCounts() { + console.log('[performance] scanning tests/performance/ for scenarios...'); + + const categoryCounts = {}; + + async function scanDir(dir, category) { + const entries = await readdir(dir, { withFileTypes: true }); + for (const entry of entries) { + const fullPath = join(dir, entry.name); + if (entry.isDirectory()) { + // Top-level subdirectory determines the category + await scanDir(fullPath, category ?? entry.name); + } else if (entry.isFile() && entry.name.endsWith('.spec.js')) { + const source = await readFile(fullPath, 'utf8'); + // Count test() calls, excluding test.skip() + const matches = source.match(/^\s*test\s*\(/gm) ?? []; + const count = matches.length; + if (count > 0 && category) { + const key = category.replace(/-/g, '_'); + categoryCounts[key] = (categoryCounts[key] ?? 0) + count; + } + } + } + } + + await scanDir('tests/performance', null); + + const total = Object.values(categoryCounts).reduce((s, n) => s + n, 0); + + const result = { tests_count: total }; + for (const [cat, count] of Object.entries(categoryCounts)) { + result[`${cat}_tests_count`] = count; + console.log(`[performance] ${cat}: ${count}`); + } + console.log(`[performance] total: ${total}`); + return result; } // --------------------------------------------------------------------------- @@ -146,22 +384,20 @@ async function main() { const stats = {}; const collectors = [ - { - key: 'component_view_tests_count', - collect: collectComponentViewTestCount, - }, - { - key: 'unit_tests_count', - collect: collectUnitTestCount, - }, + { namespace: 'component_view', collect: collectComponentViewTestCount }, + { namespace: 'unit', collect: collectUnitTestCount }, + { namespace: 'e2e', collect: collectE2ECounts }, + { namespace: 'performance', collect: collectPerformanceTestCounts }, ]; - for (const { key, collect } of collectors) { + for (const { namespace, collect } of collectors) { try { - stats[key] = await collect(); + const nested = await collect(); + if (Object.keys(nested).length === 0) continue; + stats[namespace] = nested; } catch (err) { - // stat will not be present in the output file if the collector fails - console.error(`[${key}] collector failed, skipping stat:`, err.message); + // namespace will not be present in the output if the collector fails + console.error(`[${namespace}] collector failed, skipping:`, err.message); } } diff --git a/.github/workflows/build-ios-e2e.yml b/.github/workflows/build-ios-e2e.yml index f5cd550a5f9..2865784290e 100644 --- a/.github/workflows/build-ios-e2e.yml +++ b/.github/workflows/build-ios-e2e.yml @@ -31,6 +31,9 @@ jobs: app-uploaded: ${{ steps.upload-app.outcome == 'success' }} sourcemap-uploaded: ${{ steps.upload-sourcemap.outcome == 'success' }} env: + # Bump these to bust the respective caches and force a full rebuild + XCODE_CACHE_VERSION: 1 + IOS_APP_CACHE_VERSION: 2 RCT_NO_LAUNCH_PACKAGER: 1 XCODE_BUILD_SETTINGS: 'COMPILER_INDEX_STORE_ENABLE=NO' GITHUB_CI: 'true' # This ensures it's available during pod install @@ -76,8 +79,6 @@ jobs: id: xcode-restore-cache # This action automatically updates the cache at the end of the workflow uses: cirruslabs/cache@bba69c6578b863ad0398ad40567bd2ef70290fe0 # v4 - env: - XCODE_CACHE_VERSION: 1 with: path: | ~/Library/Developer/Xcode/DerivedData @@ -89,8 +90,6 @@ jobs: id: xcode-restore-cache-main # This will only restore the cache, not update it uses: cirruslabs/cache/restore@bba69c6578b863ad0398ad40567bd2ef70290fe0 # v4 - env: - XCODE_CACHE_VERSION: 1 with: path: | ~/Library/Developer/Xcode/DerivedData @@ -159,7 +158,7 @@ jobs: with: path: | ios/build/Build/Products/Release-iphonesimulator/MetaMask.app - key: ios-app-${{ github.ref_name }}-${{ steps.generate-fingerprint.outputs.fingerprint }} + key: ios-app-${{ github.ref_name }}-v${{ env.IOS_APP_CACHE_VERSION }}-${{ steps.generate-fingerprint.outputs.fingerprint }} - name: Restore iOS app matching fingerprint from main cache if: ${{ steps.cache-restore.outputs.cache-hit != 'true' && github.ref_name != 'main' }} @@ -169,7 +168,7 @@ jobs: with: path: | ios/build/Build/Products/Release-iphonesimulator/MetaMask.app - key: ios-app-main-${{ steps.generate-fingerprint.outputs.fingerprint }} + key: ios-app-main-v${{ env.IOS_APP_CACHE_VERSION }}-${{ steps.generate-fingerprint.outputs.fingerprint }} # Build the iOS E2E app for simulator - name: Build iOS E2E App @@ -246,6 +245,33 @@ jobs: GOOGLE_SERVICES_B64_ANDROID: ${{ secrets.GOOGLE_SERVICES_B64_ANDROID }} MM_INFURA_PROJECT_ID: ${{ secrets.MM_INFURA_PROJECT_ID }} + # Xcode on case-insensitive APFS can store the bundle executable with wrong case (e.g. "metamask" + # vs CFBundleExecutable "MetaMask"), causing xcrun simctl install to fail with a case-sensitive + # directory lookup on the test runner. actions/upload-artifact also strips execute permissions. + - name: Fix iOS bundle executable case and permissions before upload + run: | + APP_PATH="ios/build/Build/Products/Release-iphonesimulator/MetaMask.app" + BUNDLE_EXEC=$(/usr/libexec/PlistBuddy -c "Print CFBundleExecutable" "$APP_PATH/Info.plist" 2>/dev/null) + if [ -z "$BUNDLE_EXEC" ]; then + echo "Could not read CFBundleExecutable from Info.plist" + exit 1 + fi + + ACTUAL_PATH=$(find "$APP_PATH" -maxdepth 1 -iname "$BUNDLE_EXEC" -type f | head -1) + if [ -z "$ACTUAL_PATH" ]; then + echo "Bundle executable not found: $BUNDLE_EXEC" + exit 1 + fi + + # Two-step rename to fix case on case-insensitive APFS (direct rename is a no-op) + if [ "$(basename "$ACTUAL_PATH")" != "$BUNDLE_EXEC" ]; then + mv "$ACTUAL_PATH" "$APP_PATH/${BUNDLE_EXEC}_fix" + mv "$APP_PATH/${BUNDLE_EXEC}_fix" "$APP_PATH/$BUNDLE_EXEC" + fi + + chmod +x "$APP_PATH/$BUNDLE_EXEC" + shell: bash + # Upload the iOS .app file that works in simulators - name: Upload iOS APP Artifact (Simulator) id: upload-app diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 464626e30e6..d7e34932157 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -254,6 +254,7 @@ jobs: shell: bash run: | mv ./tests/coverage/coverage-final.json ./tests/coverage/coverage-unit-${{ matrix.shard }}.json + cp tests/results/unit-test-results-${{ matrix.shard }}.json ./tests/coverage/jest-results.json count=$(jq '(.numPassedTests // 0) + (.numFailedTests // 0)' tests/results/unit-test-results-${{ matrix.shard }}.json) echo "{\"count\": $count}" > ./tests/coverage/count.json - uses: actions/upload-artifact@v4 @@ -388,6 +389,7 @@ jobs: shell: bash run: | mv ./tests/coverage/coverage-final.json ./tests/coverage/coverage-cv-${{ matrix.shard }}.json + cp tests/results/cv-test-results-${{ matrix.shard }}.json ./tests/coverage/jest-results.json count=$(jq '(.numPassedTests // 0) + (.numFailedTests // 0)' tests/results/cv-test-results-${{ matrix.shard }}.json) echo "{\"count\": $count}" > ./tests/coverage/count.json - uses: actions/upload-artifact@v4 @@ -584,7 +586,7 @@ jobs: continue-on-error: true uses: actions/download-artifact@v4 with: - name: test-e2e-validate-e2e-fixtures-junit-results + name: test-e2e-main-validate-e2e-fixtures-junit-results path: fixture-results/ - name: Report results diff --git a/.github/workflows/run-e2e-smoke-tests-android-flask.yml b/.github/workflows/run-e2e-smoke-tests-android-flask.yml index 27302a850bb..68c68cdb5d4 100644 --- a/.github/workflows/run-e2e-smoke-tests-android-flask.yml +++ b/.github/workflows/run-e2e-smoke-tests-android-flask.yml @@ -165,6 +165,7 @@ jobs: total_splits: 3 changed_files: ${{ inputs.changed_files }} build_type: 'flask' + artifact_name_prefix: '' secrets: inherit report-android-smoke-tests: @@ -187,7 +188,7 @@ jobs: continue-on-error: true with: path: all-test-artifacts/ - pattern: 'test-e2e-*-android-*' + pattern: 'test-e2e-flask-android-smoke-*' - name: Post Test Report uses: dorny/test-reporter@dc3a92680fcc15842eef52e8c4606ea7ce6bd3f3 diff --git a/.github/workflows/run-e2e-smoke-tests-android.yml b/.github/workflows/run-e2e-smoke-tests-android.yml index db04b526b2b..8a21808c8ee 100644 --- a/.github/workflows/run-e2e-smoke-tests-android.yml +++ b/.github/workflows/run-e2e-smoke-tests-android.yml @@ -242,7 +242,7 @@ jobs: continue-on-error: true with: path: all-test-artifacts/ - pattern: 'test-e2e-*-android-*' + pattern: 'test-e2e-main-*-android-*' - name: Post Test Report uses: dorny/test-reporter@dc3a92680fcc15842eef52e8c4606ea7ce6bd3f3 diff --git a/.github/workflows/run-e2e-smoke-tests-ios-flask.yml b/.github/workflows/run-e2e-smoke-tests-ios-flask.yml index c2a32ebdf09..269371640a7 100644 --- a/.github/workflows/run-e2e-smoke-tests-ios-flask.yml +++ b/.github/workflows/run-e2e-smoke-tests-ios-flask.yml @@ -141,6 +141,7 @@ jobs: changed_files: ${{ inputs.changed_files }} build_type: 'flask' metamask_environment: 'e2e' + artifact_name_prefix: '' secrets: inherit report-ios-smoke-tests: @@ -163,7 +164,7 @@ jobs: continue-on-error: true with: path: all-test-artifacts/ - pattern: 'test-e2e-*-ios-*' + pattern: 'test-e2e-flask-ios-smoke-*' - name: Post Test Report uses: dorny/test-reporter@dc3a92680fcc15842eef52e8c4606ea7ce6bd3f3 diff --git a/.github/workflows/run-e2e-smoke-tests-ios.yml b/.github/workflows/run-e2e-smoke-tests-ios.yml index 0532f059e65..f9fba4f7be1 100644 --- a/.github/workflows/run-e2e-smoke-tests-ios.yml +++ b/.github/workflows/run-e2e-smoke-tests-ios.yml @@ -266,7 +266,7 @@ jobs: continue-on-error: true with: path: all-test-artifacts/ - pattern: 'test-e2e-*-ios-*' + pattern: 'test-e2e-main-*-ios-*' - name: Post Test Report uses: dorny/test-reporter@dc3a92680fcc15842eef52e8c4606ea7ce6bd3f3 diff --git a/.github/workflows/run-e2e-workflow.yml b/.github/workflows/run-e2e-workflow.yml index dfb5bf77c39..f8b9df7527f 100644 --- a/.github/workflows/run-e2e-workflow.yml +++ b/.github/workflows/run-e2e-workflow.yml @@ -48,6 +48,11 @@ on: required: false type: string default: '' + artifact_name_prefix: + description: 'Optional prefix added to uploaded test artifacts' + required: false + type: string + default: 'main-' jobs: test-e2e-mobile: @@ -183,6 +188,36 @@ jobs: with: path: artifacts/ + # actions/upload-artifact strips execute permissions from the ZIP. + # Also fixes any residual case mismatch as a safety net. + - name: Restore iOS bundle executable permissions + if: ${{ inputs.platform == 'ios' }} + env: + BUILD_TYPE: ${{ inputs.build_type }} + METAMASK_ENV: ${{ inputs.metamask_environment }} + run: | + APP_PATH="artifacts/${BUILD_TYPE}-${METAMASK_ENV}-MetaMask.app" + BUNDLE_EXEC=$(/usr/libexec/PlistBuddy -c "Print CFBundleExecutable" "$APP_PATH/Info.plist" 2>/dev/null) + if [ -z "$BUNDLE_EXEC" ]; then + echo "Could not read CFBundleExecutable from Info.plist" + exit 1 + fi + + ACTUAL_PATH=$(find "$APP_PATH" -maxdepth 1 -iname "$BUNDLE_EXEC" -type f | head -1) + if [ -z "$ACTUAL_PATH" ]; then + echo "Bundle executable not found: $BUNDLE_EXEC" + exit 1 + fi + + # Two-step rename to fix case on case-insensitive APFS (direct rename is a no-op) + if [ "$(basename "$ACTUAL_PATH")" != "$BUNDLE_EXEC" ]; then + mv "$ACTUAL_PATH" "$APP_PATH/${BUNDLE_EXEC}_fix" + mv "$APP_PATH/${BUNDLE_EXEC}_fix" "$APP_PATH/$BUNDLE_EXEC" + fi + + chmod +x "$APP_PATH/$BUNDLE_EXEC" + shell: bash + # On re-run (run_attempt > 1), download previous test results to identify failed tests - name: Download previous test results (on re-run) if: ${{ github.run_attempt > 1 }} @@ -190,7 +225,7 @@ jobs: continue-on-error: true uses: actions/download-artifact@v4 with: - name: test-e2e-${{ inputs.test-suite-name }}-junit-results + name: test-e2e-${{ inputs.artifact_name_prefix }}${{ inputs.test-suite-name }}-junit-results path: ./previous-test-results/ - name: Debug - List downloaded test results (on re-run) @@ -245,6 +280,7 @@ jobs: # Without this, tests that passed in attempt N but were skipped in attempt N+1 would have no # results in the artifact, causing them to be re-run in attempt N+2. - name: Merge previous test results (on re-run) + id: merge-previous-results if: ${{ !cancelled() && github.run_attempt > 1 && steps.download-previous-results.outcome == 'success' }} continue-on-error: true run: | @@ -265,11 +301,28 @@ jobs: # Clean up temporary node_modules rm -rf node_modules temp-deps + - name: Rebuild merged JUnit after previous-results merge (on re-run) + if: ${{ !cancelled() && steps.merge-previous-results.outcome == 'success' }} + continue-on-error: true + run: | + echo "📊 Rebuilding merged junit.xml after previous-results merge..." + + mkdir -p temp-deps && cd temp-deps + npm init -y > /dev/null 2>&1 + npm install xml2js@0.6.2 --no-audit --no-fund --silent + + cp -r node_modules ${{ github.workspace }}/ + cd ${{ github.workspace }} + + node .github/scripts/e2e-merge-detox-junit-reports.mjs + + rm -rf node_modules temp-deps + - name: Upload JUnit XML results if: always() uses: actions/upload-artifact@v4 with: - name: test-e2e-${{ inputs.test-suite-name }}-junit-results + name: test-e2e-${{ inputs.artifact_name_prefix }}${{ inputs.test-suite-name }}-junit-results path: tests/reports/ retention-days: 7 @@ -277,6 +330,6 @@ jobs: if: failure() uses: actions/upload-artifact@v4 with: - name: test-e2e-${{ inputs.test-suite-name }}-screenshots + name: test-e2e-${{ inputs.artifact_name_prefix }}${{ inputs.test-suite-name }}-screenshots path: tests/artifacts/ retention-days: 7 diff --git a/CHANGELOG.md b/CHANGELOG.md index 2643b348f2b..177173fca98 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -7,6 +7,239 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ## [Unreleased] +## [7.68.0] + +### Added + +- Implemented mUSD Cashback on the Card dashboard (#26586) +- Added a persistent "Submitting your trade" toast during Perps order placement (#26432) +- Added mUSD Quick Convert flow (#26581) +- Added open perps positions section to the new wallet Homepage (#26430) +- Incoming ERC-20 token transfers from unknown senders are now hidden from the activity feed to protect against address poisoning attacks (#26235) +- Added MM fee display for Predict Withdraw to any token (#26562) +- Added gasless bridge with 7702 behind feature flag (#26513) +- Added a dedicated order details screen for Ramps V2 (Unified Buy) orders with status tracking, provider link, and processing info modal (#26462) +- Added ability to redeem a bonus code in WaysToEarn (#26097) +- Added DeFi full view screen accessible from the homepage DeFi section title (#26498) +- Added withdraw token and amount display in activity and on transaction detail page (#26223) +- Added modal for changing token or provider if token is unavailable (#26043) +- Added a Networks Management screen to the Account Menu behind a feature flag (#26336) +- Added popular tokens section on Homepage for zero balance accounts (#26379) +- Added gas sponsorship UI (#26252) +- Added market close bottom sheet to prevent trading on closed markets (#25157) +- Added trust signal icons to address displays in confirmations (#25154) +- Added card freeze/unfreeze toggle to the Card Home screen (#26246) +- Added Native Transak v2 purchase flow with in-app email/OTP authentication, KYC handling, order creation, and payment processing (#26033) +- Added support for WalletConnect verify API (#26070) +- Added Predict withdrawal to any token (#25441) +- Added amount row display when simulation fails (#25716) +- Preloaded Perps market and user data at startup for instant rendering (#26061) +- Added network pill overflow with "+X more" button in the bridge token selector (#25893) + +### Changed + +- Updated prediction buy fee details UI to improve readability and updated fee breakdown copy (#26524) +- Updated Predict withdraw toast to show the actual token symbol and amount instead of hardcoded USDC (#26418) +- Updated full-screen confirmation/loading for mUSD Quick Convert to use the bottom-sheet flow (#26437) +- Updated Add network behavior on new Network Management view (#26339) +- Updated Predict orders to use Polymarket market-specific fees when placing orders (#26518) +- Improved swaps input functionality (#26225) +- Enhanced token import with search endpoint integration, trending tokens, and modernized UI architecture (#26108) +- Replaced webview-based Veriff KYC flow with native Veriff SDK integration with MetaMask-branded UI (#26138) +- Moved notifications and QR scanner from home screen header to Account Menu and added Deposit quick action (#26100) +- Increased the browser tab limit from 5 to 20 and improved tab switching performance (#26143) +- Prefill country of residence from geolocation on Card onboarding SignUp (#26136) +- Revamped swaps keypad (#25845) +- Removed opt out button from Rewards settings (#26189) +- Removed notifications for all swap/bridge transactions (#25919) +- Removed quote details tooltip CTA and fixed paddings (#26156) +- Updated mUSD claim bonus subtitle copy (#26019) + +### Fixed + +- Fixed pull-to-refresh gesture intercepting taps on buttons near the top of the page in the in-app browser (#26373) +- Fixed Perps failure tightly coupled to trending feature (#26549) +- Fixed a potential decompression bomb vulnerability in the deeplink connection flow by adding streaming output size limits (#26542) +- Fixed the limit-order TP/SL display gap and related order-row/detail display (#25885) +- Fixed claim button text color in Predict positions header to always display white for proper contrast (#26510) +- Fixed TokenController string decimals via migration (#26338) +- Fixed first-time interaction alerts trigger for token transfer recipients (#26326) +- Fixed Bridge recipient selection to preserve external recipients and show an error state for invalid recipient addresses (#26398) +- Fixed `NetworkEnablementController.nativeAssetIdentifiers['eip155:999']` migration to `eip155:999/slip44:2457` (#26231) +- Fixed a bug where the balance check for prediction market orders did not account for fees (#26446) +- Fixed Android JNI UncaughtException error caused by WebView debugging configuration (#26391) +- Fixed order book grouping by 1 unit no longer incorrectly groups by 2 (#26331) +- Fixed an issue where validation triggered when user tries `,` separator in gas fields (#26429) +- Fixed a bug where the transaction details bottom sheet would show a stale "Pending" status after the transaction was confirmed (#26306) +- Fixed WalletConnect connection tray getting stuck or looping when scanning QR codes (#26121) +- Fixed Ledger transaction speed up and cancel transaction handling (#24745) +- Fixed balance rounding, localization formatting and decimal representation on source swap asset balance (#26267) +- Fixed keypad bottom border visible occasionally on Android (#26229) +- Fixed add/remove network confirmation toasts appearing during Bridge flows (#26239) +- Fixed an iOS bug where scanning a MetaMask universal link QR code opened Safari instead of handling in-app (#25739) +- Fixed DeFi tab not appearing when switching from non-EVM networks to "All Popular Networks" (#26193) +- Fixed quick pick button height to match confirm CTA (#26170) +- Fixed Bridge token selectors to show all supported networks, persist selected network pills, and auto-add missing networks on token selection (#26174) +- Fixed token prices not displaying for non-EVM tokens in the V2 token list layout (#26132) +- Fixed excessive ENS API calls when opening the bridge/swaps flow that scaled with the number of accounts (#26126) +- Fixed a bug where tapping a token's info icon in Swaps could open the wrong asset details page (#26123) +- Fixed perpetual trading margin display showing $0 when placing orders from the Token Details page (#26105) +- Fixed confirm button loading state not rendering on input change (#26107) +- Fixed issue that triggered account creation during onboarding using pre BIP-44 flow when switching networks (#26088) +- Fixed a UI issue where buttons in the signature message details view were overlapping (#26040) +- Fixed keypad state on flip and close it when dest token input is pressed (#26068) +- Fixed intermittent placeholder text alignment and clipping in text inputs on iOS (#26049) +- Fixed swap quote sorting to fall back to priceImpact or destTokenAmount (#25928) +- Improved claim bonus responsiveness by caching Merkl API responses (#26016) +- Fixed keyboard not hiding when scrolling in explore search (#26577) + +## [7.67.3] + +### Fixed + +- Fixed an issue with running Snaps (#26992) +- Improve reliability of persistence (#26979 +- Fixed Perps WebSocket not reconnecting after app resume from background or WiFi/network toggle (#26780) +- Remove duplicate AppState listener causing reconnection race (#26982) + +## [7.67.0] + +### Uncategorized + +- chore: merge stable into release 7.67.0 branch (#26496) +- chore: make OTA Version Display more robust (#26295) +- Bump new assets controller to v2.0.0 (#26166) +- Updated assets controllers to 99.4.0 (#26261) +- Added code fencing for gh actions defined at builds.yml (#26159) +- Fixed OTA version display (#26204) +- Remove npx in favor of yarn in sync script (#26233) +- Remove opt out button from Rewards settings (#26189) +- Removed notifications for all swap/bridge txs (#25919) +- Use chain-agnostic gas fee estimates source for bridging (#26047) +- Stop using portfolio API to fetch contentful sites (#26003) +- chore(release): sync stable to main for version 7.66.0 (#25916) +- Updated OTA modal user interface (#25867) +- Adds FAST_NETWORKS filter for gas-speed component and returns " < 1 Sec" when speed is < 1000ms (#25825) + Modifies toHumanEstimatedTimeRange in utils/time.ts to + handle "fast network" filter and " < 1 Sec" display +- Use `StorageService` in Snap Controller (#25672) +- Fixed the limit price row in Perps order form so it no longer shows rounded bottom borders when the Pay with row is visible (#25834) + below it. +- Replace modal with bottom sheet on 'Account added' click (#25770) +- chore(deps): bump the npm_and_yarn group across 1 directory with 2 updates (#25696) +- chore(release): sync stable to main for version 7.66.0 (#25802) + +### Added + +- Gas sponsorship UI (#26252) +- Replaced webview-based Veriff KYC flow with native Veriff SDK integration, featuring MetaMask-branded UI with dynamic (#26138) + light/dark theme support, custom fonts, and fox logo +- Add market close bottom sheet to stop user perform trade. (#25157) +- Added trust signal icons to address displays in confirmations, showing verified, warning, or malicious indicators based (#25154) + on address scan results. +- Moved notifications and QR scanner from home screen header to Account Menu and added Deposit quick action (#26100) +- Added card freeze/unfreeze toggle to the Card Home screen, allowing users to temporarily disable and re-enable their card. (#26246) +- `[ADDED]` Native Transak v2 purchase flow with in-app email/OTP authentication, KYC handling, order creation, and payment (#26033) + processing. +- Force enable explore feature (#26128) +- Add support for wallet connect verify api (#26070) +- Increased the browser tab limit from 5 to 20 and improved tab switching performance by keeping only the 5 most recently (#26143) + used tabs live in memory +- Prefill country of residence from geolocation on Card onboarding SignUp and extract reusable SelectField component across (#26136) + onboarding screens +- Predict withdrawal to any token (happy path) (#25441) +- Display amount row when simulation fails (#25716) +- Improved claim bonus responsiveness by caching Merkl API responses and fixed claim bonus button in token list V2 layout (#26016) +- Preloaded Perps market and user data at startup for instant rendering (#26061) +- Added network pill overflow with "+X more" button that opens a full network list in the bridge token selector (#25893) +- Revamp swaps keypad (#25845) +- Init the new assets controller under a feature flag (#25957) +- Adds a page for changing preferred ramp provider (#25860) +- Add asset overview deeplinks (#25447) +- Restored the previously selected "Pay with" token when returning to the Perps order view within 5 minutes. (#25938) +- Fixed predict transaction toast notifications not appearing when navigating away from the Predict tab (#25863) +- Added new Accounts Menu screen to organize settings navigation with Settings, Manage, and Resources sections (#25611) +- Adds Bridge and Swap feature to `MegaETH` (#25906) +- Adds chiliz.png as network logo and enables it in metamask mobile (#25437) +- Always display learn more about perps link (#25958) +- Created new token list item v2 (#25824) +- Added custom claim transaction request screen for mUSD bonus claims with improved UX flow (#25837) +- Added an "Ending soon" tab to prediction markets feed showing markets sorted by end date (#25868) +- Removed legacy homepage script injection and related RPC methods (#25620) +- Add google/web search inside browser search bar (#25897) +- Homogenize spacing on Explore page for perps items (#25894) +- Added 1st interaction alert to warn users when interacting with an address for the first time. (#25575) +- Added icons to the bridge token selector network pills (#25851) +- Create feature flag for the new unified assets state (#25891) +- Adds Bridge and Swap feature to HyperEVM (#25769) +- Added lightweight position display and one-click Long/Short trading on token details page for perps-enabled assets (#25685) +- Improved browser tab switching performance by keeping tabs mounted (#25702) +- Validation errors from non-EVM transaction snaps will now be displayed to users during send flow. (#25648) +- Added detailed transaction display for mUSD reward claims showing claimed amount, network fee, and received total (#25452) +- Adds functionality for selecting a payment method (#25681) +- Base setup for in-app provisioning (#25669) + +### Fixed + +- Adds analytics instrumentation for Token Details V2 layout A/B test (#25844) +- Fix issues with balance rounding, localization formatting and decimal representation on source swap asset balance. (#26267) +- Keypad bottom border is visible occasionally on Android (#26229) +- Adds location property to swap events. (#26067) +- Fixed a bug where add/remove network confirmation toasts appeared during Bridge flows. (#26239) +- Fixed an iOS bug where scanning a MetaMask universal link QR code opened Safari and redirected to the App Store instead of (#25739) + handling the link in-app. +- Fixed DeFi tab not appearing when switching from non-EVM networks to "All Popular Networks" (#26193) +- Set height of quick pick buttons the same as confirm cta (#26170) +- Fixed Bridge token selectors to show all supported networks, persist selected network pills, and auto-add missing networks (#26174) + on token selection. +- Fixed token prices not displaying for non-EVM tokens in the V2 token list layout. (#26132) +- Prevent full app reload when editing Trending files (#26135) +- Fixed excessive ENS API calls when opening the bridge/swaps flow that scaled with the number of accounts (#26126) +- Fixed a bug where tapping a token’s info icon in Swaps could open the wrong asset details page. (#26123) +- Fixed perpetual trading margin display showing $0 when placing orders from the Token Details page (#26105) +- Start rendering confirm button loading state on input change (#26107) +- Fixed issue that triggered account creation during onboarding using pre BIP-44 flow when switching networks (#26088) +- Fixed a UI issue where buttons in the signature message details view were overlapping. (#26040) +- Keep keypad state on flip and close it when dest token input is pressed (#26068) +- Updated mUSD claim bonus subtitle copy (#26019) +- Fixed intermittent placeholder text alignment and clipping in text inputs on iOS. (#26049) +- Fall back to priceImpact or destTokenAmount for swap quote sorting (#25928) +- Remove deeplink interstitial on dApp deeplinks (#25963) +- Multiple fixes on import token flow (#25962) +- Fixed decimal precision calculation for Tron's staked balance (#25430) +- Fixed intermittent "Failed to fetch market data" errors on Perps by switching market data fetches from WebSocket to HTTP (#26014) + transport +- Fixed `x-us-env` header being incorrectly set to `false` for US Card users when geolocation requests fail (#25971) +- Fix #24546 with human readable message (#25555) +- Removed "Add funds to start trading perps" banner from Perps market details and allow opening trades (Long/Short) when perps (#25960) + balance is zero. +- Fixed long token names pushing balance off screen in Send flow and MM Pay token picker (#25338) +- Fix #25693 styling issue in for ledger devices (#25758) +- Fixed navigation error and token buyability checks when purchasing crypto with cash using unified buy V2 (#25617) +- Fixed Predictions tab not hiding monetary values when privacy mode is enabled (#25887) +- Fixed Perps deposit+order flow so the pending deposit toast auto-dismisses after a few seconds and the "deposit taking longer" (#25939) + message appears after 30 seconds. +- Fixed header height to scale properly with larger accessibility font sizes (#25855) +- Activity header symbol fallback (#25821) +- Fixed the Perps order pay row not appearing until margin was loaded. (#25836) +- When passoword oudated, it navigate to oauthRehydrate screen when reopen app (#25687) +- Fixed notification and transaction display for EIP-7702 transactions without nonces (#25646) +- Adds event for when token details page is opened. (#25780) +- Added error screens when wallet creation fails, allowing users to retry or contact support instead of being redirected (#25564) + to login. +- Remove toggle switch from login screen (#25424) +- Fixed minor button layout issues (#25771) +- Fixed long account names overflowing in the Deposit Buy screen by enabling proper text truncation (#25715) +- Remove subtitle in token details (#25726) +- Fixed flow for "Cash buy X" button on the new token details layout (#25719) +- Pass assetID to the on ramp buy screen. (#25709) + +## [7.66.1] + +### Fixed + +- remove process.env spread (#26528) + ## [7.66.0] ### Added @@ -10615,7 +10848,11 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 - [#957](https://github.com/MetaMask/metamask-mobile/pull/957): fix timeouts (#957) - [#954](https://github.com/MetaMask/metamask-mobile/pull/954): Bugfix: onboarding navigation (#954) -[Unreleased]: https://github.com/MetaMask/metamask-mobile/compare/v7.66.0...HEAD +[Unreleased]: https://github.com/MetaMask/metamask-mobile/compare/v7.68.0...HEAD +[7.68.0]: https://github.com/MetaMask/metamask-mobile/compare/v7.67.3...v7.68.0 +[7.67.3]: https://github.com/MetaMask/metamask-mobile/compare/v7.67.0...v7.67.3 +[7.67.0]: https://github.com/MetaMask/metamask-mobile/compare/v7.66.1...v7.67.0 +[7.66.1]: https://github.com/MetaMask/metamask-mobile/compare/v7.66.0...v7.66.1 [7.66.0]: https://github.com/MetaMask/metamask-mobile/compare/v7.65.0...v7.66.0 [7.65.0]: https://github.com/MetaMask/metamask-mobile/compare/v7.64.1...v7.65.0 [7.64.1]: https://github.com/MetaMask/metamask-mobile/compare/v7.64.0...v7.64.1 diff --git a/app/components/Nav/Main/MainNavigator.js b/app/components/Nav/Main/MainNavigator.js index 931ff274193..f0ddc7914f2 100644 --- a/app/components/Nav/Main/MainNavigator.js +++ b/app/components/Nav/Main/MainNavigator.js @@ -31,6 +31,7 @@ import AddAsset from '../../Views/AddAsset/AddAsset'; import NftFullView from '../../Views/NftFullView'; import TokensFullView from '../../Views/TokensFullView'; import DeFiFullView from '../../Views/DeFiFullView'; +import CashTokensFullView from '../../Views/CashTokensFullView'; import TrendingTokensFullView from '../../UI/Trending/Views/TrendingTokensFullView/TrendingTokensFullView'; import RWATokensFullView from '../../UI/Trending/Views/RWATokensFullView/RWATokensFullView'; import { RevealPrivateCredential } from '../../Views/RevealPrivateCredential'; @@ -995,6 +996,11 @@ const MainNavigator = () => { component={DeFiFullView} options={{ headerShown: false, ...slideFromRightAnimation }} /> + + + + void }, + ...rest + }: { + children: React.ReactNode; + onClose?: () => void; + isInteractable?: boolean; + [key: string]: unknown; + }, dialogRef: React.Ref<{ onCloseDialog: () => void }>, ) => { + mockBottomSheetDialogProps({ onClose, ...rest }); MockReact.useImperativeHandle(dialogRef, () => ({ onCloseDialog: () => onClose?.(), })); @@ -53,6 +63,7 @@ describe('SwapsKeypad', () => { afterEach(() => { jest.resetAllMocks(); + mockBottomSheetDialogProps.mockReset(); }); describe('rendering', () => { @@ -388,4 +399,19 @@ describe('SwapsKeypad', () => { expect(ref.current?.isOpen()).toBe(true); }); }); + + describe('bottom sheet configuration', () => { + it('renders BottomSheetDialog with isInteractable=false to prevent PanGestureHandler double-firing keypad buttons', () => { + renderAndOpen({ + value: '0', + currency: 'native', + decimals: 18, + onChange: mockOnChange, + }); + + expect(mockBottomSheetDialogProps).toHaveBeenCalledWith( + expect.objectContaining({ isInteractable: false }), + ); + }); + }); }); diff --git a/app/components/UI/Bridge/components/SwapsKeypad/index.tsx b/app/components/UI/Bridge/components/SwapsKeypad/index.tsx index 91b29c0fb27..92758dd7083 100644 --- a/app/components/UI/Bridge/components/SwapsKeypad/index.tsx +++ b/app/components/UI/Bridge/components/SwapsKeypad/index.tsx @@ -59,7 +59,7 @@ export const SwapsKeypad = forwardRef< // Prevents the native gesture system from bubbling up diff --git a/app/components/UI/Bridge/hooks/useShouldRenderMaxOption/index.ts b/app/components/UI/Bridge/hooks/useShouldRenderMaxOption/index.ts index fe774135b87..19daf1603a3 100644 --- a/app/components/UI/Bridge/hooks/useShouldRenderMaxOption/index.ts +++ b/app/components/UI/Bridge/hooks/useShouldRenderMaxOption/index.ts @@ -1,40 +1,17 @@ -import { useMemo } from 'react'; import { useSelector } from 'react-redux'; -import { selectShouldUseSmartTransaction } from '../../../../../selectors/smartTransactionsController'; -import { RootState } from '../../../../../reducers'; +import { selectGasIncludedQuoteParams } from '../../../../../selectors/bridge'; import { BridgeToken } from '../../types'; import { useTokenAddress } from '../useTokenAddress'; -import { - formatChainIdToHex, - isNativeAddress, - isNonEvmChainId, -} from '@metamask/bridge-controller'; +import { isNativeAddress } from '@metamask/bridge-controller'; import { BigNumber } from 'bignumber.js'; -import { useIsSendBundleSupported } from '../useIsSendBundleSupported'; export const useShouldRenderMaxOption = ( token?: BridgeToken, displayBalance?: string, - isQuoteSponsored = false, + _isQuoteSponsored = false, ) => { - const evmChainId = useMemo(() => { - if (!token?.chainId || isNonEvmChainId(token.chainId)) { - return undefined; - } - return formatChainIdToHex(token.chainId); - }, [token?.chainId]); - const isSendBundleSupported = useIsSendBundleSupported(evmChainId); - const isGaslessSwapEnabled = useMemo( - () => Boolean(isSendBundleSupported), - [isSendBundleSupported], - ); - const stxEnabled = useSelector((state: RootState) => - token?.chainId && !isNonEvmChainId(token.chainId) - ? selectShouldUseSmartTransaction( - state, - formatChainIdToHex(token.chainId), - ) - : false, + const { gasIncluded, gasIncluded7702 } = useSelector( + selectGasIncludedQuoteParams, ); const tokenAddress = useTokenAddress(token); const isNativeAsset = isNativeAddress(tokenAddress); @@ -50,10 +27,5 @@ export const useShouldRenderMaxOption = ( return true; } - // Show for EVM native tokens if gasless swap is enabled OR quote is sponsored - // while smart transactions is enabled. - // For non-EVM native tokens stxEnabled will be false evaluating the whole - // expression to false. We do not know the fees beforehand so we cannot - // max out the input amount. - return (isGaslessSwapEnabled || isQuoteSponsored) && stxEnabled; + return gasIncluded || gasIncluded7702; }; diff --git a/app/components/UI/Bridge/hooks/useShouldRenderMaxOption/useShouldRenderMaxOption.test.ts b/app/components/UI/Bridge/hooks/useShouldRenderMaxOption/useShouldRenderMaxOption.test.ts index 04fd28b2ad4..33510af815a 100644 --- a/app/components/UI/Bridge/hooks/useShouldRenderMaxOption/useShouldRenderMaxOption.test.ts +++ b/app/components/UI/Bridge/hooks/useShouldRenderMaxOption/useShouldRenderMaxOption.test.ts @@ -1,15 +1,10 @@ import { renderHook } from '@testing-library/react-hooks'; import { CHAIN_IDS } from '@metamask/transaction-controller'; -import { - formatChainIdToHex, - isNativeAddress, - isNonEvmChainId, -} from '@metamask/bridge-controller'; +import { isNativeAddress } from '@metamask/bridge-controller'; import { useSelector } from 'react-redux'; import { useShouldRenderMaxOption } from '.'; import { BridgeToken } from '../../types'; import { useTokenAddress } from '../useTokenAddress'; -import { useIsSendBundleSupported } from '../useIsSendBundleSupported'; jest.mock('react-redux', () => ({ useSelector: jest.fn(), @@ -19,17 +14,11 @@ jest.mock('../useTokenAddress', () => ({ useTokenAddress: jest.fn(), })); -jest.mock('../useIsSendBundleSupported', () => ({ - useIsSendBundleSupported: jest.fn(), -})); - jest.mock('@metamask/bridge-controller', () => { const actual = jest.requireActual('@metamask/bridge-controller'); return { ...actual, - formatChainIdToHex: jest.fn(), isNativeAddress: jest.fn(), - isNonEvmChainId: jest.fn(), }; }); @@ -37,19 +26,9 @@ const mockUseSelector = useSelector as jest.MockedFunction; const mockUseTokenAddress = useTokenAddress as jest.MockedFunction< typeof useTokenAddress >; -const mockUseIsSendBundleSupported = - useIsSendBundleSupported as jest.MockedFunction< - typeof useIsSendBundleSupported - >; -const mockFormatChainIdToHex = formatChainIdToHex as jest.MockedFunction< - typeof formatChainIdToHex ->; const mockIsNativeAddress = isNativeAddress as jest.MockedFunction< typeof isNativeAddress >; -const mockIsNonEvmChainId = isNonEvmChainId as jest.MockedFunction< - typeof isNonEvmChainId ->; const mockToken: BridgeToken = { address: '0x1234567890123456789012345678901234567890', @@ -65,22 +44,23 @@ const nativeToken: BridgeToken = { chainId: CHAIN_IDS.MAINNET, }; -const setSelectorValues = ({ stxEnabled = true }: { stxEnabled?: boolean }) => { - mockUseSelector.mockImplementation(() => stxEnabled); +const setSelectorValues = ({ + gasIncluded = false, + gasIncluded7702 = false, +}: { + gasIncluded?: boolean; + gasIncluded7702?: boolean; +} = {}) => { + mockUseSelector.mockImplementation(() => ({ gasIncluded, gasIncluded7702 })); }; describe('useShouldRenderMaxOption', () => { beforeEach(() => { jest.clearAllMocks(); - setSelectorValues({ stxEnabled: true }); + setSelectorValues(); mockUseTokenAddress.mockReturnValue(mockToken.address); - mockUseIsSendBundleSupported.mockReturnValue(false); - mockFormatChainIdToHex.mockImplementation( - (chainId) => chainId as `0x${string}`, - ); mockIsNativeAddress.mockReturnValue(false); - mockIsNonEvmChainId.mockReturnValue(false); }); it('returns false when token is undefined', () => { @@ -109,11 +89,10 @@ describe('useShouldRenderMaxOption', () => { expect(result.current).toBe(true); }); - it('returns true for native token when stx and sendBundle are enabled', () => { - setSelectorValues({ stxEnabled: true }); + it('returns true for native token when gasIncluded is enabled', () => { + setSelectorValues({ gasIncluded: true }); mockUseTokenAddress.mockReturnValue(nativeToken.address); mockIsNativeAddress.mockReturnValue(true); - mockUseIsSendBundleSupported.mockReturnValue(true); const { result } = renderHook(() => useShouldRenderMaxOption(nativeToken, '1.25'), @@ -122,24 +101,28 @@ describe('useShouldRenderMaxOption', () => { expect(result.current).toBe(true); }); - it('returns false for native token when sendBundle is disabled', () => { - setSelectorValues({ stxEnabled: true }); + it('returns true for native token when 7702 is enabled', () => { + setSelectorValues({ + gasIncluded: false, + gasIncluded7702: true, + }); mockUseTokenAddress.mockReturnValue(nativeToken.address); mockIsNativeAddress.mockReturnValue(true); - mockUseIsSendBundleSupported.mockReturnValue(false); const { result } = renderHook(() => useShouldRenderMaxOption(nativeToken, '1.25'), ); - expect(result.current).toBe(false); + expect(result.current).toBe(true); }); - it('returns false for native token when stx is disabled even if sendBundle is enabled', () => { - setSelectorValues({ stxEnabled: false }); + it('returns false for native token when gasIncluded and 7702 are both disabled', () => { + setSelectorValues({ + gasIncluded: false, + gasIncluded7702: false, + }); mockUseTokenAddress.mockReturnValue(nativeToken.address); mockIsNativeAddress.mockReturnValue(true); - mockUseIsSendBundleSupported.mockReturnValue(true); const { result } = renderHook(() => useShouldRenderMaxOption(nativeToken, '1.25'), @@ -148,21 +131,26 @@ describe('useShouldRenderMaxOption', () => { expect(result.current).toBe(false); }); - it('returns true for sponsored native quote when stx is enabled', () => { - setSelectorValues({ stxEnabled: true }); + it('returns false for sponsored native quote when gasIncluded paths are disabled', () => { + setSelectorValues({ + gasIncluded: false, + gasIncluded7702: false, + }); mockUseTokenAddress.mockReturnValue(nativeToken.address); mockIsNativeAddress.mockReturnValue(true); - mockUseIsSendBundleSupported.mockReturnValue(false); const { result } = renderHook(() => useShouldRenderMaxOption(nativeToken, '1.25', true), ); - expect(result.current).toBe(true); + expect(result.current).toBe(false); }); - it('returns false for sponsored native quote when stx is disabled', () => { - setSelectorValues({ stxEnabled: false }); + it('returns true for sponsored native quote when 7702 path is enabled', () => { + setSelectorValues({ + gasIncluded: false, + gasIncluded7702: true, + }); mockUseTokenAddress.mockReturnValue(nativeToken.address); mockIsNativeAddress.mockReturnValue(true); @@ -170,38 +158,26 @@ describe('useShouldRenderMaxOption', () => { useShouldRenderMaxOption(nativeToken, '1.25', true), ); - expect(result.current).toBe(false); - }); - - it('passes formatted EVM chain id to sendBundle hook', () => { - const chainId = '0xa' as `0x${string}`; - const formattedChainId = '0xa' as `0x${string}`; - const token = { ...nativeToken, chainId }; - mockUseTokenAddress.mockReturnValue(token.address); - mockIsNativeAddress.mockReturnValue(true); - mockFormatChainIdToHex.mockReturnValue(formattedChainId); - - renderHook(() => useShouldRenderMaxOption(token, '1.25')); - - expect(mockUseIsSendBundleSupported).toHaveBeenCalledWith(formattedChainId); + expect(result.current).toBe(true); }); - it('passes undefined chain id to sendBundle hook for non-EVM token', () => { + it('returns false for non-EVM native token when no gas-included path is enabled', () => { const solanaToken: BridgeToken = { ...nativeToken, chainId: 'solana:5eykt4UsFv8P8NJdTREpY1vzqKqZKvdp' as `${string}:${string}`, }; + setSelectorValues({ + gasIncluded: false, + gasIncluded7702: false, + }); mockUseTokenAddress.mockReturnValue(solanaToken.address); mockIsNativeAddress.mockReturnValue(true); - mockIsNonEvmChainId.mockReturnValue(true); - setSelectorValues({ stxEnabled: false }); const { result } = renderHook(() => useShouldRenderMaxOption(solanaToken, '3'), ); - expect(mockUseIsSendBundleSupported).toHaveBeenCalledWith(undefined); expect(result.current).toBe(false); }); }); diff --git a/app/components/UI/Earn/constants/events/musdEvents.ts b/app/components/UI/Earn/constants/events/musdEvents.ts index 0304ec5f2e2..ae5e370c332 100644 --- a/app/components/UI/Earn/constants/events/musdEvents.ts +++ b/app/components/UI/Earn/constants/events/musdEvents.ts @@ -4,6 +4,8 @@ const EVENT_PROVIDERS = { const EVENT_LOCATIONS = { HOME_SCREEN: 'home', + /** Cash section on homepage (aggregated mUSD row or empty state "Get mUSD") */ + HOME_CASH_SECTION: 'home_cash_section', TOKEN_LIST_ITEM: 'token_list_item', ASSET_OVERVIEW: 'asset_overview', CONVERSION_EDUCATION_SCREEN: 'conversion_education_screen', diff --git a/app/components/UI/Predict/components/PredictUnavailable/PredictUnavailable.tsx b/app/components/UI/Predict/components/PredictUnavailable/PredictUnavailable.tsx index 16cfb2b3f79..e53ea4fa4c9 100644 --- a/app/components/UI/Predict/components/PredictUnavailable/PredictUnavailable.tsx +++ b/app/components/UI/Predict/components/PredictUnavailable/PredictUnavailable.tsx @@ -21,8 +21,8 @@ import { strings } from '../../../../../../locales/i18n'; // Internal dependencies. import BottomSheet from '../../../../../component-library/components/BottomSheets/BottomSheet/BottomSheet'; import { BottomSheetRef } from '../../../../../component-library/components/BottomSheets/BottomSheet/BottomSheet.types'; -import BottomSheetHeader from '../../../../../component-library/components/BottomSheets/BottomSheetHeader/BottomSheetHeader'; import BottomSheetFooter from '../../../../../component-library/components/BottomSheets/BottomSheetFooter/BottomSheetFooter'; +import HeaderCompactStandard from '../../../../../component-library/components-temp/HeaderCompactStandard'; import { ButtonVariants } from '../../../../../component-library/components/Buttons/Button/Button.types'; interface PredictUnavailableProps { @@ -115,11 +115,11 @@ const PredictUnavailable = forwardRef< isInteractable onClose={handleSheetClosed} > - - - {strings('predict.unavailable.title')} - - + - + {strings('predict.unavailable.description')}{' '} ); diff --git a/app/components/UI/Ramp/Views/Modals/ProviderSelectionModal/ProviderSelection.tsx b/app/components/UI/Ramp/Views/Modals/ProviderSelectionModal/ProviderSelection.tsx index 561d3f4dda0..e21facaca68 100644 --- a/app/components/UI/Ramp/Views/Modals/ProviderSelectionModal/ProviderSelection.tsx +++ b/app/components/UI/Ramp/Views/Modals/ProviderSelectionModal/ProviderSelection.tsx @@ -324,7 +324,7 @@ const ProviderSelection: React.FC = ({ message={ showQuotes ? strings('fiat_on_ramp.no_quotes_available') - : strings('fiat_on_ramp.no_providers_available') + : strings('fiat_on_ramp_aggregator.no_providers_available') } severity={BannerAlertSeverity.Error} /> diff --git a/app/components/UI/TokenDetails/Views/TokenDetails.test.tsx b/app/components/UI/TokenDetails/Views/TokenDetails.test.tsx index d53f60f0332..a8f001dbd4d 100644 --- a/app/components/UI/TokenDetails/Views/TokenDetails.test.tsx +++ b/app/components/UI/TokenDetails/Views/TokenDetails.test.tsx @@ -73,22 +73,17 @@ jest.mock('../hooks/useTokenBalance', () => ({ useTokenBalance: () => mockUseTokenBalance(), })); +const mockUseTokenBuyability = jest.fn(); jest.mock('../../Ramp/hooks/useTokenBuyability', () => ({ - useTokenBuyability: () => ({ isBuyable: true, isLoading: false }), + __esModule: true, + default: (...args: unknown[]) => mockUseTokenBuyability(...args), })); -const mockHandleBuyPress = jest.fn(); -const mockHandleSellPress = jest.fn(); +const mockGoToSwaps = jest.fn(); +const mockOnBuy = jest.fn(); +const mockUseTokenActions = jest.fn(); jest.mock('../hooks/useTokenActions', () => ({ - useTokenActions: () => ({ - onBuy: jest.fn(), - onSend: jest.fn(), - onReceive: jest.fn(), - goToSwaps: jest.fn(), - handleBuyPress: mockHandleBuyPress, - handleSellPress: mockHandleSellPress, - networkModal: null, - }), + useTokenActions: () => mockUseTokenActions(), })); const defaultUseTokenTransactionsReturn = { @@ -211,6 +206,20 @@ describe('TokenDetails', () => { }); mockIsTokenTradingOpen.mockReturnValue(true); mockUseTokenTransactions.mockReturnValue(defaultUseTokenTransactionsReturn); + mockUseTokenBuyability.mockReturnValue({ + isBuyable: true, + isLoading: false, + }); + mockUseTokenActions.mockReturnValue({ + onBuy: mockOnBuy, + onSend: jest.fn(), + onReceive: jest.fn(), + goToSwaps: mockGoToSwaps, + handleBuyPress: jest.fn(), + handleSellPress: jest.fn(), + hasEligibleSwapTokens: true, + networkModal: null, + }); mockUseTokenBalance.mockReturnValue({ balance: '1.5', @@ -241,11 +250,12 @@ describe('TokenDetails', () => { expect(UNSAFE_getByType(ActivityIndicator)).toBeTruthy(); }); - describe('Buy/Sell sticky buttons', () => { + describe('Swap/Buy sticky buttons', () => { it('shows sticky buttons when useNewLayout is true (treatment variant)', () => { const { getByTestId, getByText } = render(); expect(getByTestId('bottomsheetfooter')).toBeOnTheScreen(); + expect(getByText('Swap')).toBeOnTheScreen(); expect(getByText('Buy')).toBeOnTheScreen(); }); @@ -269,43 +279,41 @@ describe('TokenDetails', () => { expect(queryByTestId('bottomsheetfooter')).toBeNull(); }); - it('shows both Buy and Sell buttons when token has balance > 0', () => { - mockUseTokenBalance.mockReturnValue({ - balance: '10.5', - fiatBalance: '$1050.00', - tokenFormattedBalance: '10.5 DAI', - }); - + it('shows both Swap and Buy when user has eligible tokens and token is buyable', () => { const { getByText } = render(); + expect(getByText('Swap')).toBeOnTheScreen(); expect(getByText('Buy')).toBeOnTheScreen(); - expect(getByText('Sell')).toBeOnTheScreen(); }); - it('shows only Buy button when token has no balance', () => { - mockUseTokenBalance.mockReturnValue({ - balance: '0', - fiatBalance: '$0.00', - tokenFormattedBalance: '0 DAI', + it('shows only Swap when user has eligible tokens but token is not buyable', () => { + mockUseTokenBuyability.mockReturnValue({ + isBuyable: false, + isLoading: false, }); const { getByText, queryByText } = render(); - expect(getByText('Buy')).toBeOnTheScreen(); - expect(queryByText('Sell')).toBeNull(); + expect(getByText('Swap')).toBeOnTheScreen(); + expect(queryByText('Buy')).toBeNull(); }); - it('shows only Buy button when token balance is undefined', () => { - mockUseTokenBalance.mockReturnValue({ - balance: undefined, - fiatBalance: undefined, - tokenFormattedBalance: undefined, + it('shows only Buy when user has no eligible swap tokens', () => { + mockUseTokenActions.mockReturnValue({ + onBuy: mockOnBuy, + onSend: jest.fn(), + onReceive: jest.fn(), + goToSwaps: mockGoToSwaps, + handleBuyPress: jest.fn(), + handleSellPress: jest.fn(), + hasEligibleSwapTokens: false, + networkModal: null, }); const { getByText, queryByText } = render(); expect(getByText('Buy')).toBeOnTheScreen(); - expect(queryByText('Sell')).toBeNull(); + expect(queryByText('Swap')).toBeNull(); }); }); diff --git a/app/components/UI/TokenDetails/Views/TokenDetails.tsx b/app/components/UI/TokenDetails/Views/TokenDetails.tsx index 1b4fa9fa39f..8eec9e9fa1c 100644 --- a/app/components/UI/TokenDetails/Views/TokenDetails.tsx +++ b/app/components/UI/TokenDetails/Views/TokenDetails.tsx @@ -48,6 +48,7 @@ import { strings } from '../../../../../locales/i18n'; import { useTokenDetailsABTest } from '../hooks/useTokenDetailsABTest'; import { useRWAToken } from '../../Bridge/hooks/useRWAToken'; import { BridgeToken } from '../../Bridge/types'; +import useTokenBuyability from '../../Ramp/hooks/useTokenBuyability'; const styleSheet = (params: { theme: Theme }) => { const { theme } = params; @@ -148,14 +149,15 @@ const TokenDetails: React.FC<{ onSend, onReceive, goToSwaps, - handleBuyPress, - handleSellPress, + hasEligibleSwapTokens, networkModal, } = useTokenActions({ token, networkName, }); + const { isBuyable } = useTokenBuyability(token); + const { transactions, submittedTxs, @@ -178,6 +180,9 @@ const TokenDetails: React.FC<{ }); const displaySwapsButton = isSwapsAssetAllowed && AppConstants.SWAPS.ACTIVE; + const showSwapButton = hasEligibleSwapTokens; + const showBuyButton = isBuyable || !hasEligibleSwapTokens; + const rampNetworks = useSelector(getRampNetworks); const chainIdForRamp = token.chainId ?? ''; @@ -277,7 +282,6 @@ const TokenDetails: React.FC<{ {networkModal} {useNewLayout && !txLoading && - displaySwapsButton && isTokenTradingOpen(token as BridgeToken) && ( 0 + ...(showSwapButton + ? [ + { + variant: ButtonVariants.Primary, + label: strings('asset_overview.swap'), + size: ButtonSize.Lg, + onPress: () => goToSwaps(), + }, + ] + : []), + ...(showBuyButton ? [ { variant: ButtonVariants.Primary, - label: strings('asset_overview.sell_button'), + label: strings('asset_overview.buy_button'), size: ButtonSize.Lg, - onPress: handleSellPress, + onPress: onBuy, }, ] : []), diff --git a/app/components/UI/TokenDetails/hooks/useTokenActions.ts b/app/components/UI/TokenDetails/hooks/useTokenActions.ts index 4c575a512fb..45fc936fa1d 100644 --- a/app/components/UI/TokenDetails/hooks/useTokenActions.ts +++ b/app/components/UI/TokenDetails/hooks/useTokenActions.ts @@ -99,6 +99,8 @@ export interface UseTokenActionsResult { handleBuyPress: () => void; /** Sticky bar Sell handler - current asset as source, mUSD/native as destination */ handleSellPress: () => void; + /** Whether the user has any tokens with positive balance that can be used as a swap source */ + hasEligibleSwapTokens: boolean; networkModal: React.ReactNode; } @@ -468,6 +470,7 @@ export const useTokenActions = ({ goToSwaps, handleBuyPress, handleSellPress, + hasEligibleSwapTokens: buySourceToken !== null, networkModal, }; }; diff --git a/app/components/UI/Tokens/TokenList/TokenListItem/TokenListItem.test.tsx b/app/components/UI/Tokens/TokenList/TokenListItem/TokenListItem.test.tsx index 0db7adc3417..90d7df11f6b 100644 --- a/app/components/UI/Tokens/TokenList/TokenListItem/TokenListItem.test.tsx +++ b/app/components/UI/Tokens/TokenList/TokenListItem/TokenListItem.test.tsx @@ -24,7 +24,10 @@ import { selectMusdQuickConvertEnabledFlag, selectStablecoinLendingEnabledFlag, } from '../../../Earn/selectors/featureFlags'; -import { MUSD_CONVERSION_APY } from '../../../Earn/constants/musd'; +import { + MUSD_CONVERSION_APY, + MUSD_TOKEN_ADDRESS, +} from '../../../Earn/constants/musd'; import { EARN_EXPERIENCES } from '../../../Earn/constants/experiences'; import { MUSD_CONVERSION_NAVIGATION_OVERRIDE } from '../../../Earn/types/musd.types'; @@ -1248,10 +1251,12 @@ describe('TokenListItem - Component Rendering Tests for Coverage', () => { }); describe('Merkl Claim Bonus', () => { - // Use an address that isTokenEligibleForMerklRewards would accept + // Use mUSD address so isMusdToken(asset.address) is true and we show "3% bonus" when not claimable const claimableAsset = { ...defaultAsset, - address: '0x8d652c6d4A8F3Db96Cd866C1a9220B1447F29898', + address: MUSD_TOKEN_ADDRESS, + symbol: 'mUSD', + name: 'MetaMask USD', chainId: '0x1', }; @@ -1280,11 +1285,12 @@ describe('TokenListItem - Component Rendering Tests for Coverage', () => { expect(getByText(strings('earn.claim_bonus'))).toBeOnTheScreen(); }); - it('hides "Claim bonus" CTA when claimableReward is null', () => { + it('shows green "3% bonus" when mUSD and claimableReward is null', () => { prepareMocks({ asset: claimableAsset, pricePercentChange1d: 2.0, claimableReward: null, + isMusdConversionEnabled: true, }); const { queryByText, getByText } = renderWithProvider( @@ -1298,6 +1304,69 @@ describe('TokenListItem - Component Rendering Tests for Coverage', () => { ); expect(queryByText(strings('earn.claim_bonus'))).toBeNull(); + expect( + getByText( + strings('earn.musd_conversion.percentage_bonus', { + percentage: MUSD_CONVERSION_APY, + }), + ), + ).toBeOnTheScreen(); + }); + + it('shows normal percentage when mUSD but conversion flow is disabled', () => { + prepareMocks({ + asset: claimableAsset, + pricePercentChange1d: 2.0, + claimableReward: null, + isMusdConversionEnabled: false, + }); + + const { queryByText, getByText } = renderWithProvider( + , + ); + + expect( + queryByText( + strings('earn.musd_conversion.percentage_bonus', { + percentage: MUSD_CONVERSION_APY, + }), + ), + ).toBeNull(); + expect(getByText('+2.00%')).toBeOnTheScreen(); + }); + + it('shows normal percentage when mUSD but user is geo-blocked', () => { + prepareMocks({ + asset: claimableAsset, + pricePercentChange1d: 2.0, + claimableReward: null, + isMusdConversionEnabled: true, + isGeoEligible: false, + }); + + const { queryByText, getByText } = renderWithProvider( + , + ); + + expect( + queryByText( + strings('earn.musd_conversion.percentage_bonus', { + percentage: MUSD_CONVERSION_APY, + }), + ), + ).toBeNull(); expect(getByText('+2.00%')).toBeOnTheScreen(); }); diff --git a/app/components/UI/Tokens/TokenList/TokenListItem/TokenListItem.tsx b/app/components/UI/Tokens/TokenList/TokenListItem/TokenListItem.tsx index 35b87dcf9a2..d248eb23c74 100644 --- a/app/components/UI/Tokens/TokenList/TokenListItem/TokenListItem.tsx +++ b/app/components/UI/Tokens/TokenList/TokenListItem/TokenListItem.tsx @@ -26,9 +26,11 @@ import { TokenI } from '../../types'; import { ScamWarningIcon } from './ScamWarningIcon/ScamWarningIcon'; import { FlashListAssetKey } from '../TokenList'; import { + selectIsMusdConversionFlowEnabledFlag, selectMusdQuickConvertEnabledFlag, selectStablecoinLendingEnabledFlag, } from '../../../Earn/selectors/featureFlags'; +import { useMusdConversionEligibility } from '../../../Earn/hooks/useMusdConversionEligibility'; import { useTokenPricePercentageChange } from '../../hooks/useTokenPricePercentageChange'; import { selectAsset } from '../../../../../selectors/assets/assets-list'; import Tag from '../../../../../component-library/components/Tags/Tag'; @@ -146,6 +148,11 @@ export const TokenListItem = React.memo( selectMusdQuickConvertEnabledFlag, ); + const isMusdConversionFlowEnabled = useSelector( + selectIsMusdConversionFlowEnabledFlag, + ); + const { isEligible: isMusdGeoEligible } = useMusdConversionEligibility(); + const { getEarnToken } = useEarnTokens(); const earnToken = getEarnToken(asset as TokenI); @@ -294,6 +301,22 @@ export const TokenListItem = React.memo( }; } + // mUSD with no claimable bonus: show green "3% bonus" (not clickable) + if ( + isMusdConversionFlowEnabled && + isMusdGeoEligible && + asset && + isMusdToken(asset.address) + ) { + return { + text: strings('earn.musd_conversion.percentage_bonus', { + percentage: MUSD_CONVERSION_APY, + }), + color: TextColor.Success, + onPress: undefined, + }; + } + if (shouldShowConvertToMusdCta) { return { text: strings('earn.musd_conversion.get_a_percentage_musd_bonus', { @@ -336,6 +359,9 @@ export const TokenListItem = React.memo( return { text, color, onPress: undefined }; }, [ + asset, + isMusdConversionFlowEnabled, + isMusdGeoEligible, hasClaimableBonus, shouldShowConvertToMusdCta, isStablecoinLendingEnabled, diff --git a/app/components/UI/Tokens/TokenList/TokenListItemV2/TokenListItemV2.test.tsx b/app/components/UI/Tokens/TokenList/TokenListItemV2/TokenListItemV2.test.tsx index 93ba3aca864..475e2654d56 100644 --- a/app/components/UI/Tokens/TokenList/TokenListItemV2/TokenListItemV2.test.tsx +++ b/app/components/UI/Tokens/TokenList/TokenListItemV2/TokenListItemV2.test.tsx @@ -30,7 +30,10 @@ import { selectMusdQuickConvertEnabledFlag, selectStablecoinLendingEnabledFlag, } from '../../../Earn/selectors/featureFlags'; -import { MUSD_CONVERSION_APY } from '../../../Earn/constants/musd'; +import { + MUSD_CONVERSION_APY, + MUSD_TOKEN_ADDRESS, +} from '../../../Earn/constants/musd'; import { EARN_EXPERIENCES } from '../../../Earn/constants/experiences'; import { MUSD_CONVERSION_NAVIGATION_OVERRIDE } from '../../../Earn/types/musd.types'; @@ -1220,7 +1223,9 @@ describe('TokenListItemV2 - Component Rendering Tests for Coverage', () => { describe('Merkl Claim Bonus', () => { const claimableAsset = { ...defaultAsset, - address: '0x8d652c6d4A8F3Db96Cd866C1a9220B1447F29898', + address: MUSD_TOKEN_ADDRESS, + symbol: 'mUSD', + name: 'MetaMask USD', }; const assetKey: FlashListAssetKey = { address: claimableAsset.address, @@ -1342,11 +1347,12 @@ describe('TokenListItemV2 - Component Rendering Tests for Coverage', () => { expect(mockClaimRewards).toHaveBeenCalledTimes(1); }); - it('falls back to percentage when claimableReward is null', () => { + it('shows green "3% bonus" when mUSD and claimableReward is null', () => { prepareMocks({ asset: claimableAsset, pricePercentChange1d: 1.5, claimableReward: null, + isMusdConversionEnabled: true, }); const { queryByText, getByText } = renderWithProvider( @@ -1360,6 +1366,69 @@ describe('TokenListItemV2 - Component Rendering Tests for Coverage', () => { ); expect(queryByText(strings('earn.claim_bonus'))).toBeNull(); + expect( + getByText( + strings('earn.musd_conversion.percentage_bonus', { + percentage: MUSD_CONVERSION_APY, + }), + ), + ).toBeOnTheScreen(); + }); + + it('shows normal percentage when mUSD but conversion flow is disabled', () => { + prepareMocks({ + asset: claimableAsset, + pricePercentChange1d: 1.5, + claimableReward: null, + isMusdConversionEnabled: false, + }); + + const { queryByText, getByText } = renderWithProvider( + , + ); + + expect( + queryByText( + strings('earn.musd_conversion.percentage_bonus', { + percentage: MUSD_CONVERSION_APY, + }), + ), + ).toBeNull(); + expect(getByText('+1.50%')).toBeOnTheScreen(); + }); + + it('shows normal percentage when mUSD but user is geo-blocked', () => { + prepareMocks({ + asset: claimableAsset, + pricePercentChange1d: 1.5, + claimableReward: null, + isMusdConversionEnabled: true, + isGeoEligible: false, + }); + + const { queryByText, getByText } = renderWithProvider( + , + ); + + expect( + queryByText( + strings('earn.musd_conversion.percentage_bonus', { + percentage: MUSD_CONVERSION_APY, + }), + ), + ).toBeNull(); expect(getByText('+1.50%')).toBeOnTheScreen(); }); }); diff --git a/app/components/UI/Tokens/TokenList/TokenListItemV2/TokenListItemV2.tsx b/app/components/UI/Tokens/TokenList/TokenListItemV2/TokenListItemV2.tsx index 66b3c67c486..61196afe365 100644 --- a/app/components/UI/Tokens/TokenList/TokenListItemV2/TokenListItemV2.tsx +++ b/app/components/UI/Tokens/TokenList/TokenListItemV2/TokenListItemV2.tsx @@ -22,9 +22,11 @@ import { ScamWarningIcon } from '../TokenListItem/ScamWarningIcon/ScamWarningIco import useIsOriginalNativeTokenSymbol from '../../../../hooks/useIsOriginalNativeTokenSymbol/useIsOriginalNativeTokenSymbol'; import { FlashListAssetKey } from '../TokenList'; import { + selectIsMusdConversionFlowEnabledFlag, selectMusdQuickConvertEnabledFlag, selectStablecoinLendingEnabledFlag, } from '../../../Earn/selectors/featureFlags'; +import { useMusdConversionEligibility } from '../../../Earn/hooks/useMusdConversionEligibility'; import { useTokenPricePercentageChange } from '../../hooks/useTokenPricePercentageChange'; import { selectAsset } from '../../../../../selectors/assets/assets-list'; import Tag from '../../../../../component-library/components/Tags/Tag'; @@ -211,6 +213,11 @@ export const TokenListItemV2 = React.memo( selectMusdQuickConvertEnabledFlag, ); + const isMusdConversionFlowEnabled = useSelector( + selectIsMusdConversionFlowEnabledFlag, + ); + const { isEligible: isMusdGeoEligible } = useMusdConversionEligibility(); + const { getEarnToken } = useEarnTokens(); const earnToken = getEarnToken(asset as TokenI); @@ -420,6 +427,22 @@ export const TokenListItemV2 = React.memo( }; } + // mUSD with no claimable bonus: show green "3% bonus" (not clickable) + if ( + isMusdConversionFlowEnabled && + isMusdGeoEligible && + asset && + isMusdToken(asset.address) + ) { + return { + text: strings('earn.musd_conversion.percentage_bonus', { + percentage: MUSD_CONVERSION_APY, + }), + color: CLTextColor.Success, + onPress: undefined, + }; + } + if (shouldShowConvertToMusdCta) { return { text: strings('earn.musd_conversion.get_a_percentage_musd_bonus', { @@ -462,12 +485,15 @@ export const TokenListItemV2 = React.memo( return { text, color, onPress: undefined }; }, [ + isMusdConversionFlowEnabled, + isMusdGeoEligible, hasClaimableBonus, shouldShowConvertToMusdCta, isStablecoinLendingEnabled, earnToken?.experience?.type, hasPercentageChange, pricePercentChange1d, + asset, handleClaimBonus, handleConvertToMUSD, handleLendingRedirect, diff --git a/app/components/UI/Tokens/TokenListControlBar/TokenListControlBar.test.tsx b/app/components/UI/Tokens/TokenListControlBar/TokenListControlBar.test.tsx index f61b6f5234e..05004b1751a 100644 --- a/app/components/UI/Tokens/TokenListControlBar/TokenListControlBar.test.tsx +++ b/app/components/UI/Tokens/TokenListControlBar/TokenListControlBar.test.tsx @@ -320,7 +320,29 @@ describe('TokenListControlBar', () => { const { getByTestId } = renderComponent(); const addTokenButton = getByTestId('import-token-button'); - expect(addTokenButton.props.disabled).toBe(false); + expect(addTokenButton.props.disabled).toBeFalsy(); + }); + }); + + describe('showAddToken and hideSort (Cash view)', () => { + it('does not render add token button when showAddToken is false', () => { + const { queryByTestId } = renderComponent({ + ...defaultProps, + showAddToken: false, + }); + + expect(queryByTestId('import-token-button')).toBeNull(); + }); + + it('renders network filter when showAddToken is false', () => { + const { getByTestId } = renderComponent({ + ...defaultProps, + showAddToken: false, + }); + + expect( + getByTestId(WalletViewSelectorsIDs.TOKEN_NETWORK_FILTER), + ).toBeOnTheScreen(); }); }); }); diff --git a/app/components/UI/Tokens/TokenListControlBar/TokenListControlBar.tsx b/app/components/UI/Tokens/TokenListControlBar/TokenListControlBar.tsx index 24308d6e36e..f524cca3093 100644 --- a/app/components/UI/Tokens/TokenListControlBar/TokenListControlBar.tsx +++ b/app/components/UI/Tokens/TokenListControlBar/TokenListControlBar.tsx @@ -1,10 +1,11 @@ import React from 'react'; import { ViewStyle } from 'react-native'; +import { + ButtonIcon, + ButtonIconSize, + IconName, +} from '@metamask/design-system-react-native'; import { WalletViewSelectorsIDs } from '../../../Views/Wallet/WalletView.testIds'; -import { IconName } from '../../../../component-library/components/Icons/Icon'; -import ButtonIcon, { - ButtonIconSizes, -} from '../../../../component-library/components/Buttons/ButtonIcon'; import BaseControlBar from '../../shared/BaseControlBar/BaseControlBar'; import { useStyles } from '../../../hooks/useStyles'; import createControlBarStyles from '../../shared/ControlBarStyles'; @@ -12,23 +13,34 @@ import createControlBarStyles from '../../shared/ControlBarStyles'; interface TokenListControlBarProps { goToAddToken: () => void; style?: ViewStyle; + /** + * When false, only the network filter is shown (e.g. Cash / mUSD-only view). + * Default true for the main token list. + */ + showAddToken?: boolean; + /** + * When true, hide the sort button (e.g. Cash view where sorting one token type is unnecessary). + */ + hideSort?: boolean; } export const TokenListControlBar = ({ goToAddToken, style, + showAddToken = true, + hideSort = false, }: TokenListControlBarProps) => { const { styles } = useStyles(createControlBarStyles, undefined); - const additionalButtons = ( + const additionalButtons = showAddToken ? ( - ); + ) : undefined; return ( ); }; diff --git a/app/components/UI/Tokens/index.test.tsx b/app/components/UI/Tokens/index.test.tsx index 484581b01cc..507ee64df26 100644 --- a/app/components/UI/Tokens/index.test.tsx +++ b/app/components/UI/Tokens/index.test.tsx @@ -18,6 +18,7 @@ import { WalletViewSelectorsIDs } from '../../Views/Wallet/WalletView.testIds'; import { TokenList } from './TokenList/TokenList'; import { ScrollView } from 'react-native-gesture-handler'; import { TokenI } from './types'; +import { MUSD_TOKEN_ADDRESS } from '../Earn/constants/musd'; // eslint-disable-next-line import/no-namespace import * as MusdConversionAssetListCtaModule from '../Earn/components/Musd/MusdConversionAssetListCta'; // eslint-disable-next-line import/no-namespace @@ -30,13 +31,14 @@ import * as RefreshTokensModule from './util/refreshTokens'; import * as RemoveEvmTokenModule from './util/removeEvmToken'; // eslint-disable-next-line import/no-namespace import * as RemoveNonEvmTokenModule from './util/removeNonEvmToken'; +const mockUseMusdConversionEligibility = jest.fn(() => ({ + isEligible: true, + isLoading: false, + geolocation: 'US', + blockedCountries: [], +})); jest.mock('../Earn/hooks/useMusdConversionEligibility', () => ({ - useMusdConversionEligibility: () => ({ - isEligible: true, - isLoading: false, - geolocation: 'US', - blockedCountries: [], - }), + useMusdConversionEligibility: () => mockUseMusdConversionEligibility(), })); // Mocking versioning for some selectors @@ -82,15 +84,26 @@ const arrangeMockComponents = () => { const mockTokenListControlBar = jest .spyOn(TokenListControlBarModule, 'TokenListControlBar') - .mockImplementation(({ goToAddToken }) => ( - - + )} + + + ); +}; + +CashGetMusdEmptyState.displayName = 'CashGetMusdEmptyState'; + +export default CashGetMusdEmptyState; +export { CashGetMusdEmptyStateSelectors }; diff --git a/app/components/Views/Homepage/Sections/Cash/CashSection.test.tsx b/app/components/Views/Homepage/Sections/Cash/CashSection.test.tsx new file mode 100644 index 00000000000..52f455181a9 --- /dev/null +++ b/app/components/Views/Homepage/Sections/Cash/CashSection.test.tsx @@ -0,0 +1,148 @@ +import React from 'react'; +import { screen, fireEvent } from '@testing-library/react-native'; +import renderWithProvider from '../../../../../util/test/renderWithProvider'; +import CashSection from './CashSection'; +import Routes from '../../../../../constants/navigation/Routes'; + +const mockNavigate = jest.fn(); + +jest.mock('@react-navigation/native', () => ({ + ...jest.requireActual('@react-navigation/native'), + useNavigation: () => ({ + navigate: mockNavigate, + }), +})); + +jest.mock('../../../../UI/Earn/selectors/featureFlags', () => ({ + selectIsMusdConversionFlowEnabledFlag: jest.fn(() => true), +})); + +const mockUseMusdConversionEligibility = jest.fn(() => ({ isEligible: true })); +jest.mock('../../../../UI/Earn/hooks/useMusdConversionEligibility', () => ({ + useMusdConversionEligibility: () => mockUseMusdConversionEligibility(), +})); + +const mockUseMusdBalance = jest.fn(() => ({ + hasMusdBalanceOnAnyChain: false, + tokenBalanceAggregated: '0', + fiatBalanceAggregatedFormatted: '$0.00', +})); +jest.mock('../../../../UI/Earn/hooks/useMusdBalance', () => ({ + useMusdBalance: () => mockUseMusdBalance(), +})); + +jest.mock('../../hooks/useHomeViewedEvent', () => ({ + __esModule: true, + default: jest.fn(), + HomeSectionNames: { + CASH: 'cash', + TOKENS: 'tokens', + PERPS: 'perps', + DEFI: 'defi', + PREDICT: 'predict', + NFTS: 'nfts', + }, +})); + +jest.mock('./MusdAggregatedRow', () => { + const { Text } = jest.requireActual('react-native'); + const ReactActual = jest.requireActual('react'); + return { + __esModule: true, + default: () => ReactActual.createElement(Text, null, 'MusdAggregatedRow'), + }; +}); + +jest.mock('./CashGetMusdEmptyState', () => { + const { Text, View } = jest.requireActual('react-native'); + const ReactActual = jest.requireActual('react'); + return { + __esModule: true, + default: () => + ReactActual.createElement( + View, + { testID: 'cash-get-musd-empty-state' }, + ReactActual.createElement(Text, null, 'Get mUSD'), + ), + }; +}); + +describe('CashSection', () => { + beforeEach(() => { + jest.clearAllMocks(); + jest + .requireMock('../../../../UI/Earn/selectors/featureFlags') + .selectIsMusdConversionFlowEnabledFlag.mockReturnValue(true); + mockUseMusdConversionEligibility.mockReturnValue({ isEligible: true }); + mockUseMusdBalance.mockReturnValue({ + hasMusdBalanceOnAnyChain: false, + tokenBalanceAggregated: '0', + fiatBalanceAggregatedFormatted: '$0.00', + }); + }); + + it('returns null when mUSD conversion is disabled', () => { + jest + .requireMock('../../../../UI/Earn/selectors/featureFlags') + .selectIsMusdConversionFlowEnabledFlag.mockReturnValue(false); + + const { queryByText } = renderWithProvider( + , + ); + + expect(queryByText('Cash')).toBeNull(); + }); + + it('returns null when geo is ineligible', () => { + mockUseMusdConversionEligibility.mockReturnValue({ isEligible: false }); + + const { queryByText } = renderWithProvider( + , + ); + + expect(queryByText('Cash')).toBeNull(); + }); + + it('renders Cash title when enabled', () => { + renderWithProvider( + , + ); + + expect(screen.getByText('Cash')).toBeOnTheScreen(); + }); + + it('navigates to CASH_TOKENS_FULL_VIEW when section header is pressed', () => { + renderWithProvider( + , + ); + + fireEvent.press(screen.getByText('Cash')); + + expect(mockNavigate).toHaveBeenCalledWith( + Routes.WALLET.CASH_TOKENS_FULL_VIEW, + ); + }); + + it('shows Get mUSD empty state when user has no mUSD balance', () => { + renderWithProvider( + , + ); + + expect(screen.getByTestId('cash-get-musd-empty-state')).toBeOnTheScreen(); + expect(screen.getByText('Get mUSD')).toBeOnTheScreen(); + }); + + it('renders MusdAggregatedRow when user has mUSD balance', () => { + mockUseMusdBalance.mockReturnValue({ + hasMusdBalanceOnAnyChain: true, + tokenBalanceAggregated: '1800', + fiatBalanceAggregatedFormatted: '$1,800.00', + }); + + renderWithProvider( + , + ); + + expect(screen.getByText('MusdAggregatedRow')).toBeOnTheScreen(); + }); +}); diff --git a/app/components/Views/Homepage/Sections/Cash/CashSection.tsx b/app/components/Views/Homepage/Sections/Cash/CashSection.tsx new file mode 100644 index 00000000000..eb388d3d792 --- /dev/null +++ b/app/components/Views/Homepage/Sections/Cash/CashSection.tsx @@ -0,0 +1,88 @@ +import React, { useCallback, useRef } from 'react'; +import { View } from 'react-native'; +import { useNavigation } from '@react-navigation/native'; +import { useSelector } from 'react-redux'; +import { Box } from '@metamask/design-system-react-native'; +import SectionHeader from '../../../../../component-library/components-temp/SectionHeader'; +import SectionRow from '../../components/SectionRow'; +import Routes from '../../../../../constants/navigation/Routes'; +import { strings } from '../../../../../../locales/i18n'; +import useHomeViewedEvent, { + HomeSectionNames, +} from '../../hooks/useHomeViewedEvent'; +import { selectIsMusdConversionFlowEnabledFlag } from '../../../../UI/Earn/selectors/featureFlags'; +import { useMusdConversionEligibility } from '../../../../UI/Earn/hooks/useMusdConversionEligibility'; +import { useMusdBalance } from '../../../../UI/Earn/hooks/useMusdBalance'; +import MusdAggregatedRow from './MusdAggregatedRow'; + +import CashGetMusdEmptyState from './CashGetMusdEmptyState'; +import Logger from '../../../../../util/Logger'; + +interface CashSectionProps { + sectionIndex: number; + totalSectionsLoaded: number; +} + +/** + * CashSection - Displays mUSD (MetaMask USD) as the first homepage section. + * Shows aggregated mUSD balance across supported networks and optional "Claim bonus". + * Section header navigates to the Cash token list page (mUSD-only, per network). + */ +const CashSection = ({ + sectionIndex, + totalSectionsLoaded, +}: CashSectionProps) => { + const sectionViewRef = useRef(null); + const navigation = useNavigation(); + const isMusdConversionEnabled = useSelector( + selectIsMusdConversionFlowEnabledFlag, + ); + const { isEligible: isGeoEligible } = useMusdConversionEligibility(); + const { hasMusdBalanceOnAnyChain } = useMusdBalance(); + + const isCashSectionEnabled = isMusdConversionEnabled && isGeoEligible; + + const handleViewCashTokens = useCallback(() => { + navigation.navigate(Routes.WALLET.CASH_TOKENS_FULL_VIEW as never); + }, [navigation]); + + useHomeViewedEvent({ + sectionRef: sectionViewRef, + isLoading: false, + sectionName: HomeSectionNames.CASH, + sectionIndex, + totalSectionsLoaded, + isEmpty: !hasMusdBalanceOnAnyChain, + itemCount: hasMusdBalanceOnAnyChain ? 1 : 0, + }); + + if (!isCashSectionEnabled) { + Logger.log( + `[CashSection] not rendered flag=${isMusdConversionEnabled} geo=${isGeoEligible} reason=${!isMusdConversionEnabled ? 'flag_off' : 'geo_ineligible'}`, + ); + return null; + } + + const title = strings('homepage.sections.cash'); + + return ( + + + + {!hasMusdBalanceOnAnyChain ? ( + + + + ) : ( + + + + )} + + + ); +}; + +CashSection.displayName = 'CashSection'; + +export default CashSection; diff --git a/app/components/Views/Homepage/Sections/Cash/MusdAggregatedRow.test.tsx b/app/components/Views/Homepage/Sections/Cash/MusdAggregatedRow.test.tsx new file mode 100644 index 00000000000..aead65d138c --- /dev/null +++ b/app/components/Views/Homepage/Sections/Cash/MusdAggregatedRow.test.tsx @@ -0,0 +1,112 @@ +import React from 'react'; +import { fireEvent, screen } from '@testing-library/react-native'; +import renderWithProvider from '../../../../../util/test/renderWithProvider'; +import MusdAggregatedRow from './MusdAggregatedRow'; + +const mockClaimRewards = jest.fn(); +const mockTrackEvent = jest.fn(); +const mockCreateEventBuilder = jest.fn(() => ({ + addProperties: jest.fn().mockReturnThis(), + build: jest.fn(), +})); + +jest.mock('../../../../UI/Earn/hooks/useMusdBalance', () => ({ + useMusdBalance: () => ({ + tokenBalanceAggregated: '1800.5', + fiatBalanceAggregatedFormatted: '$1,800.50', + }), +})); + +const mockUseMerklBonusClaim = jest.fn(() => ({ + claimableReward: { amount: '10' } as { amount: string } | null, + hasPendingClaim: false, + claimRewards: mockClaimRewards, + isClaiming: false, +})); +jest.mock( + '../../../../UI/Earn/components/MerklRewards/hooks/useMerklBonusClaim', + () => ({ + useMerklBonusClaim: () => mockUseMerklBonusClaim(), + }), +); + +jest.mock('../../../../../selectors/preferencesController', () => ({ + selectPrivacyMode: () => false, +})); + +jest.mock('../../../../Views/confirmations/hooks/useNetworkName', () => ({ + useNetworkName: () => 'Linea Mainnet', +})); + +jest.mock('../../../../hooks/useAnalytics/useAnalytics', () => ({ + useAnalytics: () => ({ + trackEvent: mockTrackEvent, + createEventBuilder: mockCreateEventBuilder, + }), +})); + +describe('MusdAggregatedRow', () => { + beforeEach(() => { + jest.clearAllMocks(); + mockUseMerklBonusClaim.mockReturnValue({ + claimableReward: { amount: '10' }, + hasPendingClaim: false, + claimRewards: mockClaimRewards, + isClaiming: false, + }); + }); + + it('renders token name and balances', () => { + renderWithProvider(); + + expect(screen.getByText('MetaMask USD')).toBeOnTheScreen(); + expect(screen.getByText('$1,800.50')).toBeOnTheScreen(); + expect(screen.getByText(/1,800\.5\s*mUSD/)).toBeOnTheScreen(); + }); + + it('renders Claim bonus when claimable and taps call claimRewards and trackEvent', () => { + renderWithProvider(); + + const claimButton = screen.getByText('Claim bonus'); + expect(claimButton).toBeOnTheScreen(); + + fireEvent.press(claimButton); + + expect(mockClaimRewards).toHaveBeenCalled(); + expect(mockTrackEvent).toHaveBeenCalled(); + expect(mockCreateEventBuilder).toHaveBeenCalled(); + }); + + it('has cash-section-musd-row testID', () => { + renderWithProvider(); + expect(screen.getByTestId('cash-section-musd-row')).toBeOnTheScreen(); + }); + + it('shows Spinner when isClaiming is true', () => { + mockUseMerklBonusClaim.mockReturnValue({ + claimableReward: { amount: '10' }, + hasPendingClaim: false, + claimRewards: mockClaimRewards, + isClaiming: true, + }); + + renderWithProvider(); + + expect(screen.getByTestId('cash-section-musd-row')).toBeOnTheScreen(); + expect(screen.queryByText('Claim bonus')).toBeNull(); + }); + + it('shows green "3% bonus" when not claimable', () => { + mockUseMerklBonusClaim.mockReturnValue({ + claimableReward: null, + hasPendingClaim: false, + claimRewards: mockClaimRewards, + isClaiming: false, + }); + + renderWithProvider(); + + expect(screen.queryByText('Claim bonus')).toBeNull(); + expect(screen.getByText('3% bonus')).toBeOnTheScreen(); + }); +}); diff --git a/app/components/Views/Homepage/Sections/Cash/MusdAggregatedRow.tsx b/app/components/Views/Homepage/Sections/Cash/MusdAggregatedRow.tsx new file mode 100644 index 00000000000..2fc9e5ac0de --- /dev/null +++ b/app/components/Views/Homepage/Sections/Cash/MusdAggregatedRow.tsx @@ -0,0 +1,191 @@ +import React, { useCallback } from 'react'; +import { Pressable } from 'react-native'; +import { useTailwind } from '@metamask/design-system-twrnc-preset'; +import { + Box, + Text, + TextVariant, + TextColor, + FontWeight, + BoxFlexDirection, + BoxAlignItems, + BoxJustifyContent, + AvatarToken, + AvatarTokenSize, +} from '@metamask/design-system-react-native'; +import SensitiveText, { + SensitiveTextLength, +} from '../../../../../component-library/components/Texts/SensitiveText'; +import { + TextVariant as CLTextVariant, + TextColor as CLTextColor, +} from '../../../../../component-library/components/Texts/Text/Text.types'; +import AnimatedSpinner, { SpinnerSize } from '../../../../UI/AnimatedSpinner'; +import { useSelector } from 'react-redux'; +import I18n, { strings } from '../../../../../../locales/i18n'; +import { getIntlNumberFormatter } from '../../../../../util/intl'; +import { + MUSD_CONVERSION_APY, + MUSD_TOKEN, + MUSD_TOKEN_ADDRESS, +} from '../../../../UI/Earn/constants/musd'; +import { MUSD_EVENTS_CONSTANTS } from '../../../../UI/Earn/constants/events'; +import { useNetworkName } from '../../../../Views/confirmations/hooks/useNetworkName'; +import type { Hex } from '@metamask/utils'; +import { CHAIN_IDS } from '@metamask/transaction-controller'; +import { useMusdBalance } from '../../../../UI/Earn/hooks/useMusdBalance'; +import { useMerklBonusClaim } from '../../../../UI/Earn/components/MerklRewards/hooks/useMerklBonusClaim'; +import { TokenI } from '../../../../UI/Tokens/types'; +import { selectPrivacyMode } from '../../../../../selectors/preferencesController'; +import { useAnalytics } from '../../../../hooks/useAnalytics/useAnalytics'; +import { MetaMetricsEvents } from '../../../../../core/Analytics'; +import { MUSD_MAINNET_ASSET_FOR_DETAILS } from './CashGetMusdEmptyState.constants'; +import NavigationService from '../../../../../core/NavigationService'; +import { TokenDetailsSource } from '../../../../UI/TokenDetails/constants/constants'; + +/** + * Minimal mUSD asset for useMerklBonusClaim (claim runs on Linea). + * Only chainId and address are required for the claim flow. + */ +const LINEA_MUSD_ASSET: TokenI = { + chainId: CHAIN_IDS.LINEA_MAINNET as string, + address: MUSD_TOKEN_ADDRESS, + symbol: MUSD_TOKEN.symbol, + name: MUSD_TOKEN.name, + decimals: MUSD_TOKEN.decimals, + image: '', + balance: '0', + isETH: false, + logo: undefined, +}; + +const MusdAggregatedRow = () => { + const tw = useTailwind(); + const privacyMode = useSelector(selectPrivacyMode); + const { tokenBalanceAggregated, fiatBalanceAggregatedFormatted } = + useMusdBalance(); + const { claimableReward, hasPendingClaim, claimRewards, isClaiming } = + useMerklBonusClaim(LINEA_MUSD_ASSET); + const { trackEvent, createEventBuilder } = useAnalytics(); + const networkName = useNetworkName(LINEA_MUSD_ASSET.chainId as Hex); + + const hasClaimableBonus = Boolean(claimableReward) && !hasPendingClaim; + + const handleClaimBonus = useCallback(() => { + trackEvent( + createEventBuilder(MetaMetricsEvents.MUSD_CLAIM_BONUS_BUTTON_CLICKED) + .addProperties({ + action_type: 'claim_bonus', + button_text: strings('earn.claim_bonus'), + location: MUSD_EVENTS_CONSTANTS.EVENT_LOCATIONS.HOME_CASH_SECTION, + network_chain_id: LINEA_MUSD_ASSET.chainId, + network_name: networkName ?? undefined, + asset_symbol: LINEA_MUSD_ASSET.symbol, + }) + .build(), + ); + claimRewards(); + }, [trackEvent, createEventBuilder, networkName, claimRewards]); + + const handleTokenRowPress = useCallback(() => { + NavigationService.navigation.navigate( + 'Asset' as never, + { + ...MUSD_MAINNET_ASSET_FOR_DETAILS, + source: TokenDetailsSource.MobileTokenListPage, + } as never, + ); + }, []); + + const tokenBalanceDisplay = `${getIntlNumberFormatter(I18n.locale, { + minimumFractionDigits: 0, + maximumFractionDigits: 2, + }).format(Number(tokenBalanceAggregated))} ${MUSD_TOKEN.symbol}`; + + return ( + + tw.style('flex-row items-center py-1', pressed && 'opacity-80') + } + testID="cash-section-musd-row" + onPress={handleTokenRowPress} + > + + + + + + {MUSD_TOKEN.name} + + + {fiatBalanceAggregatedFormatted} + + + + {isClaiming ? ( + + ) : hasClaimableBonus ? ( + + + {strings('earn.claim_bonus')} + + + ) : ( + + {strings('earn.musd_conversion.percentage_bonus', { + percentage: MUSD_CONVERSION_APY, + })} + + )} + + {tokenBalanceDisplay} + + + + + + ); +}; + +export default MusdAggregatedRow; diff --git a/app/components/Views/Homepage/Sections/Cash/index.ts b/app/components/Views/Homepage/Sections/Cash/index.ts new file mode 100644 index 00000000000..53f4fa56a27 --- /dev/null +++ b/app/components/Views/Homepage/Sections/Cash/index.ts @@ -0,0 +1,2 @@ +export { default as CashSection } from './CashSection'; +export { default as MusdAggregatedRow } from './MusdAggregatedRow'; diff --git a/app/components/Views/Homepage/Sections/Tokens/TokensSection.test.tsx b/app/components/Views/Homepage/Sections/Tokens/TokensSection.test.tsx index d0a76d1d8ae..af873319c30 100644 --- a/app/components/Views/Homepage/Sections/Tokens/TokensSection.test.tsx +++ b/app/components/Views/Homepage/Sections/Tokens/TokensSection.test.tsx @@ -78,6 +78,15 @@ jest.mock('../../../../../selectors/networkController', () => ({ selectNetworkConfigurations: jest.fn(() => mockNetworkConfigurations), })); +jest.mock('../../../../UI/Earn/selectors/featureFlags', () => ({ + selectIsMusdConversionFlowEnabledFlag: jest.fn(() => false), +})); + +const mockUseMusdConversionEligibility = jest.fn(() => ({ isEligible: false })); +jest.mock('../../../../UI/Earn/hooks/useMusdConversionEligibility', () => ({ + useMusdConversionEligibility: () => mockUseMusdConversionEligibility(), +})); + const mockRefreshTokens = jest.fn().mockResolvedValue(undefined); jest.mock('../../../../UI/Tokens/util/refreshTokens', () => ({ refreshTokens: (...args: unknown[]) => mockRefreshTokens(...args), @@ -349,6 +358,11 @@ describe('TokensSection', () => { error: null, refetch: jest.fn(), }); + // Cash section disabled by default so TokensSection shows all tokens (including mUSD) unless a test opts in. + jest + .requireMock('../../../../UI/Earn/selectors/featureFlags') + .selectIsMusdConversionFlowEnabledFlag.mockReturnValue(false); + mockUseMusdConversionEligibility.mockReturnValue({ isEligible: false }); }); it('renders section title for account with balance', () => { @@ -460,6 +474,30 @@ describe('TokensSection', () => { expect(screen.queryByTestId('token-item-0xtoken7')).toBeNull(); }); + it('filters out mUSD from displayed tokens (mUSD is shown only in Cash section)', () => { + const MUSD_ADDRESS = '0xaca92e438df0b2401ff60da7e4337b687a2435da'; + jest + .requireMock('../../../../UI/Earn/selectors/featureFlags') + .selectIsMusdConversionFlowEnabledFlag.mockReturnValue(true); + mockUseMusdConversionEligibility.mockReturnValue({ isEligible: true }); + mockUseIsZeroBalanceAccount.mockReturnValue(false); + mockSortedTokenKeys.mockReturnValue([ + { chainId: '0x1', address: MUSD_ADDRESS, isStaked: false }, + { chainId: '0x1', address: '0xtoken1', isStaked: false }, + ]); + + renderWithProvider( + , + ); + + expect(screen.queryByTestId(`token-item-${MUSD_ADDRESS}`)).toBeNull(); + expect(screen.queryByTestId(`token-item-v2-${MUSD_ADDRESS}`)).toBeNull(); + const otherToken = + screen.queryByTestId('token-item-0xtoken1') ?? + screen.queryByTestId('token-item-v2-0xtoken1'); + expect(otherToken).toBeOnTheScreen(); + }); + it('navigates to tokens full view on title press', () => { mockUseIsZeroBalanceAccount.mockReturnValue(false); diff --git a/app/components/Views/Homepage/Sections/Tokens/TokensSection.tsx b/app/components/Views/Homepage/Sections/Tokens/TokensSection.tsx index 9fd10a20e70..0f57ac50976 100644 --- a/app/components/Views/Homepage/Sections/Tokens/TokensSection.tsx +++ b/app/components/Views/Homepage/Sections/Tokens/TokensSection.tsx @@ -41,6 +41,9 @@ import useHomeViewedEvent, { HomeSectionNames, } from '../../hooks/useHomeViewedEvent'; import { useMusdCtaVisibility } from '../../../../UI/Earn/hooks/useMusdCtaVisibility'; +import { isMusdToken } from '../../../../UI/Earn/constants/musd'; +import { selectIsMusdConversionFlowEnabledFlag } from '../../../../UI/Earn/selectors/featureFlags'; +import { useMusdConversionEligibility } from '../../../../UI/Earn/hooks/useMusdConversionEligibility'; interface TokensSectionProps { sectionIndex: number; @@ -117,21 +120,37 @@ const TokensSection = forwardRef( } }, [selectedAccountId]); + const isMusdConversionFlowEnabled = useSelector( + selectIsMusdConversionFlowEnabledFlag, + ); + const { isEligible: isGeoEligible } = useMusdConversionEligibility(); + const isCashSectionEnabled = isMusdConversionFlowEnabled && isGeoEligible; + const title = strings('homepage.sections.tokens'); + // Only exclude mUSD when Cash section is enabled (then mUSD is shown there). Otherwise include all. const displayTokenKeys = useMemo( - () => sortedTokenKeys.slice(0, MAX_TOKENS_DISPLAYED), - [sortedTokenKeys], + () => + sortedTokenKeys + .filter((key) => + isCashSectionEnabled ? !isMusdToken(key.address) : true, + ) + .slice(0, MAX_TOKENS_DISPLAYED), + [sortedTokenKeys, isCashSectionEnabled], ); // Show error when an explicit refresh failed, or when balance data has loaded // and the account has balance but the selector returned no tokens (controllers // failed to load data). The accountGroupBalance null-check prevents a false // positive on cold start or for legitimately empty token lists. + // When Cash section is enabled, displayTokenKeys can be empty because we filter + // out mUSD (shown in Cash section); do not treat "balance but no non-mUSD tokens" + // as an error. const hasBalanceButNoTokens = accountGroupBalance != null && accountGroupBalance.totalBalanceInUserCurrency > 0 && - displayTokenKeys.length === 0; + displayTokenKeys.length === 0 && + (!isCashSectionEnabled || sortedTokenKeys.length === 0); const showTokensError = hasTokensError || hasBalanceButNoTokens; const refresh = useCallback(async () => { diff --git a/app/components/Views/Homepage/Sections/Tokens/hooks/usePopularTokens.test.ts b/app/components/Views/Homepage/Sections/Tokens/hooks/usePopularTokens.test.ts index 6604909280a..a58319dbd84 100644 --- a/app/components/Views/Homepage/Sections/Tokens/hooks/usePopularTokens.test.ts +++ b/app/components/Views/Homepage/Sections/Tokens/hooks/usePopularTokens.test.ts @@ -20,6 +20,8 @@ jest.mock('../../../../../../selectors/currencyRateController', () => ({ jest.mock('../../../../../UI/Earn/constants/musd', () => ({ MUSD_CONVERSION_APY: 3, MUSD_TOKEN_ADDRESS: '0xaca92e438df0b2401ff60da7e4337b687a2435da', + isMusdToken: (address?: string) => + address?.toLowerCase() === '0xaca92e438df0b2401ff60da7e4337b687a2435da', })); // Mock locales to avoid deep import chain issues @@ -31,6 +33,15 @@ jest.mock('../../../../../../../locales/i18n', () => ({ ), })); +const mockUseMusdConversionEligibility = jest.fn(() => ({ isEligible: false })); +jest.mock('../../../../../UI/Earn/hooks/useMusdConversionEligibility', () => ({ + useMusdConversionEligibility: () => mockUseMusdConversionEligibility(), +})); + +jest.mock('../../../../../UI/Earn/selectors/featureFlags', () => ({ + selectIsMusdConversionFlowEnabledFlag: jest.fn(), +})); + const mockUseSelector = useSelector as jest.MockedFunction; const mockHandleFetch = handleFetch as jest.MockedFunction; @@ -193,4 +204,27 @@ describe('usePopularTokens', () => { expect(mockHandleFetch).toHaveBeenCalledTimes(2); }); + + it('excludes mUSD from tokens when Cash section is enabled', async () => { + mockHandleFetch.mockResolvedValue({}); + mockUseMusdConversionEligibility.mockReturnValue({ isEligible: true }); + // First call: selectCurrentCurrency → 'usd'; second: selectIsMusdConversionFlowEnabledFlag → true. + // Later calls (re-renders) keep Cash enabled so mUSD stays filtered. + mockUseSelector + .mockReturnValueOnce('usd') + .mockReturnValueOnce(true) + .mockReturnValue(true); + + const { result } = renderHook(() => usePopularTokens()); + + await waitFor(() => { + expect(result.current.isInitialLoading).toBe(false); + }); + + expect(result.current.tokens).toHaveLength(4); + expect( + result.current.tokens.find((t) => t.symbol === 'mUSD'), + ).toBeUndefined(); + expect(result.current.tokens[0].name).toBe('Ethereum'); + }); }); diff --git a/app/components/Views/Homepage/Sections/Tokens/hooks/usePopularTokens.ts b/app/components/Views/Homepage/Sections/Tokens/hooks/usePopularTokens.ts index a5d257573f0..15ed0abb5ba 100644 --- a/app/components/Views/Homepage/Sections/Tokens/hooks/usePopularTokens.ts +++ b/app/components/Views/Homepage/Sections/Tokens/hooks/usePopularTokens.ts @@ -6,7 +6,10 @@ import { strings } from '../../../../../../../locales/i18n'; import { MUSD_CONVERSION_APY, MUSD_TOKEN_ADDRESS, + isMusdToken, } from '../../../../../UI/Earn/constants/musd'; +import { selectIsMusdConversionFlowEnabledFlag } from '../../../../../UI/Earn/selectors/featureFlags'; +import { useMusdConversionEligibility } from '../../../../../UI/Earn/hooks/useMusdConversionEligibility'; /** * Popular token metadata with CAIP-19 asset IDs @@ -115,6 +118,11 @@ const getTokenDescription = ( */ export const usePopularTokens = () => { const currentCurrency = useSelector(selectCurrentCurrency); + const isMusdConversionFlowEnabled = useSelector( + selectIsMusdConversionFlowEnabledFlag, + ); + const { isEligible: isGeoEligible } = useMusdConversionEligibility(); + const isCashSectionEnabled = isMusdConversionFlowEnabled && isGeoEligible; const [rawTokens, setRawTokens] = useState< { assetId: string; @@ -209,25 +217,28 @@ export const usePopularTokens = () => { [], ); - // Add descriptions dynamically (localized strings must be called within component) - const tokens: PopularToken[] = useMemo( - () => - rawTokens.map((token) => { - const baseToken = POPULAR_TOKENS.find( - (t) => t.assetId === token.assetId, - ); - return { - assetId: token.assetId, - name: token.name, - symbol: token.symbol, - iconUrl: token.iconUrl, - price: token.price, - priceChange1d: token.priceChange1d, - description: baseToken ? getTokenDescription(baseToken) : undefined, - }; - }), - [rawTokens], - ); + // Add descriptions dynamically (localized strings must be called within component). + // When Cash section is enabled, exclude mUSD from this list (it is shown in Cash section). + const tokens: PopularToken[] = useMemo(() => { + const mapped = rawTokens.map((token) => { + const baseToken = POPULAR_TOKENS.find((t) => t.assetId === token.assetId); + return { + assetId: token.assetId, + name: token.name, + symbol: token.symbol, + iconUrl: token.iconUrl, + price: token.price, + priceChange1d: token.priceChange1d, + description: baseToken ? getTokenDescription(baseToken) : undefined, + }; + }); + return isCashSectionEnabled + ? mapped.filter((t) => { + const address = t.assetId.split(':').pop(); + return !isMusdToken(address); + }) + : mapped; + }, [rawTokens, isCashSectionEnabled]); return { tokens, diff --git a/app/components/Views/Homepage/hooks/useHomeSessionSummary.ts b/app/components/Views/Homepage/hooks/useHomeSessionSummary.ts index 24008d7649e..0166cfdfd05 100644 --- a/app/components/Views/Homepage/hooks/useHomeSessionSummary.ts +++ b/app/components/Views/Homepage/hooks/useHomeSessionSummary.ts @@ -51,7 +51,6 @@ const useHomeSessionSummary = ({ useFocusEffect( useCallback( () => () => { - // Blur — user is leaving the homepage. Skip if never actually focused. if (visitIdRef.current === 0) return; const sessionTime = Math.round( (Date.now() - sessionStartRef.current) / 1000, diff --git a/app/components/Views/Homepage/hooks/useHomeViewedEvent.ts b/app/components/Views/Homepage/hooks/useHomeViewedEvent.ts index 25ecaa3ce85..26ffeb10d0c 100644 --- a/app/components/Views/Homepage/hooks/useHomeViewedEvent.ts +++ b/app/components/Views/Homepage/hooks/useHomeViewedEvent.ts @@ -5,6 +5,7 @@ import { useAnalytics } from '../../../hooks/useAnalytics/useAnalytics'; import { useHomepageScrollContext } from '../context/HomepageScrollContext'; export const HomeSectionNames = { + CASH: 'cash', TOKENS: 'tokens', PERPS: 'perps', DEFI: 'defi', diff --git a/app/components/Views/ManualBackupStep1/index.tsx b/app/components/Views/ManualBackupStep1/index.tsx index 5565d78c9c6..bb3e1b452b4 100644 --- a/app/components/Views/ManualBackupStep1/index.tsx +++ b/app/components/Views/ManualBackupStep1/index.tsx @@ -50,7 +50,10 @@ import Button, { ButtonWidthTypes, ButtonSize, } from '../../../component-library/components/Buttons/Button'; -import Label from '../../../component-library/components/Form/Label'; +import { + Label, + TextColor as DSTextColor, +} from '@metamask/design-system-react-native'; import TextField from '../../../component-library/components/Form/TextField/TextField'; import { saveOnboardingEvent as saveEvent } from '../../../actions/onboarding'; import { AppThemeKey } from '../../../util/theme/models'; @@ -334,7 +337,7 @@ const ManualBackupStep1 = () => { - diff --git a/app/components/Views/OAuthRehydration/index.tsx b/app/components/Views/OAuthRehydration/index.tsx index 40861623ab0..343c8404603 100644 --- a/app/components/Views/OAuthRehydration/index.tsx +++ b/app/components/Views/OAuthRehydration/index.tsx @@ -81,7 +81,11 @@ import { AnalyticsEventBuilder } from '../../../util/analytics/AnalyticsEventBui import { useAnalytics } from '../../hooks/useAnalytics/useAnalytics'; import FOX_LOGO from '../../../images/branding/fox.png'; import METAMASK_NAME from '../../../images/branding/metamask-name.png'; -import Label from '../../../component-library/components/Form/Label'; +import { + Label, + FontWeight, + TextColor as DSTextColor, +} from '@metamask/design-system-react-native'; import TextField from '../../../component-library/components/Form/TextField'; import HelpText, { HelpTextSeverity, @@ -703,8 +707,8 @@ const OAuthRehydration: React.FC = ({