diff --git a/.github/cursorPrompts/issue-analysis.md b/.github/cursorPrompts/issue-analysis.md new file mode 100644 index 00000000000..8f157f8b143 --- /dev/null +++ b/.github/cursorPrompts/issue-analysis.md @@ -0,0 +1,8 @@ +@cursor Analyze this MetaMask issue and provide: + +1. **Problem**: What's broken (2-3 sentences) +2. **Root Cause**: 1-2 likely causes based on evidence +3. **Target Repo(s)**: Which repo needs the fix (this repo/core/transaction-controller/other controllers in the core repo) +4. **Solutions**: 2-3 approaches with pros/cons, recommend the best one + +If a solution is clear, suggest a code diff (highlight with + and - what have changed) that would resolve the issue, but do not open or create a pull request. diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 891769ad184..c98639dc692 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -337,27 +337,13 @@ jobs: # Flask E2E tests - build-android-flask-apks: - name: 'Build Android Flask APKs' - if: ${{ github.event_name != 'merge_group' && needs.needs_e2e_build.outputs.android_changed == 'true' }} - permissions: - contents: read - id-token: write - needs: [needs_e2e_build] - uses: ./.github/workflows/build-android-e2e.yml - with: - build_type: 'flask' - metamask_environment: 'e2e' - keystore_target: 'flask' - secrets: inherit - e2e-smoke-tests-android-flask: name: 'Android Flask E2E Smoke Tests' if: ${{ github.event_name != 'merge_group' && needs.needs_e2e_build.outputs.android_changed == 'true' }} permissions: contents: read id-token: write - needs: [needs_e2e_build, build-android-flask-apks] + needs: [needs_e2e_build, build-android-apks] uses: ./.github/workflows/run-e2e-smoke-tests-android-flask.yml with: changed_files: ${{ needs.needs_e2e_build.outputs.changed_files }} diff --git a/.github/workflows/cursor-issue-analysis.yml b/.github/workflows/cursor-issue-analysis.yml new file mode 100644 index 00000000000..5e8333fc583 --- /dev/null +++ b/.github/workflows/cursor-issue-analysis.yml @@ -0,0 +1,57 @@ +name: Cursor Issue Analysis + +on: + issues: + types: [opened, labeled] + +permissions: + issues: write + contents: read + +jobs: + analyze-issue: + runs-on: ubuntu-latest + # Check if issue has team-confirmations AND (Sev1-high OR Sev2-normal) + if: | + contains(github.event.issue.labels.*.name, 'team-confirmations') && + (contains(github.event.issue.labels.*.name, 'Sev1-high') || contains(github.event.issue.labels.*.name, 'Sev2-normal')) + steps: + - name: Check for existing @cursor comment + id: check-comment + uses: actions/github-script@v6 + with: + script: | + const comments = await github.paginate(github.rest.issues.listComments, { + owner: context.repo.owner, + repo: context.repo.repo, + issue_number: context.issue.number + }); + + const hasCursorComment = comments.some(comment => + comment.body.trim().startsWith('@cursor') + ); + + core.setOutput('exists', hasCursorComment); + return hasCursorComment; + + - name: Checkout repository + if: steps.check-comment.outputs.exists != 'true' + uses: actions/checkout@v4 + with: + sparse-checkout: .github/cursorPrompts + sparse-checkout-cone-mode: false + + - name: Add @cursor analysis comment + if: steps.check-comment.outputs.exists != 'true' + uses: actions/github-script@v6 + with: + script: | + const fs = require('fs'); + const body = fs.readFileSync('.github/cursorPrompts/issue-analysis.md', 'utf8'); + + await github.rest.issues.createComment({ + owner: context.repo.owner, + repo: context.repo.repo, + issue_number: context.issue.number, + body: body + }); diff --git a/.github/workflows/run-e2e-smoke-tests-android-flask.yml b/.github/workflows/run-e2e-smoke-tests-android-flask.yml index 7bbaf3d9bc0..a04b69e140e 100644 --- a/.github/workflows/run-e2e-smoke-tests-android-flask.yml +++ b/.github/workflows/run-e2e-smoke-tests-android-flask.yml @@ -14,7 +14,123 @@ permissions: id-token: write jobs: + repack-android-flask-apps: + name: 'Repack Android Flask Apps' + runs-on: ghcr.io/cirruslabs/ubuntu-runner-amd64:24.04-lg + permissions: + contents: read + id-token: write + steps: + - name: Checkout + uses: actions/checkout@v4 + + - name: Setup Node.js + uses: actions/setup-node@v4 + with: + node-version-file: '.nvmrc' + cache: 'yarn' + + - name: Install dependencies + uses: nick-fields/retry@ce71cc2ab81d554ebbe88c79ab5975992d79ba08 #v3.0.2 + with: + timeout_minutes: 10 + max_attempts: 3 + retry_wait_seconds: 30 + command: yarn install --immutable + + - name: Setup project + run: yarn setup:github-ci --no-build-ios + + - name: Configure Keystore + uses: MetaMask/github-tools/.github/actions/configure-keystore@0259e8a920318b02a8860e178d79796eaa08de02 + with: + aws-role-to-assume: ${{ secrets.METAMASK_MOBILE_BUILDER_SIGNER_QA }} + aws-region: us-east-2 + platform: android + target: qa + env: + ANDROID_KEYSTORE_PATH: android/keystores/internalRelease.keystore + + - name: Download Main APK artifacts + uses: actions/download-artifact@v4 + with: + path: artifacts/ + pattern: main-e2e-release*.apk + + - name: Setup Main APK artifacts + run: | + mkdir -p android/app/build/outputs/apk/prod/release/ + mkdir -p android/app/build/outputs/apk/androidTest/prod/release/ + cp artifacts/main-e2e-release.apk/app-prod-release.apk android/app/build/outputs/apk/prod/release/app-prod-release.apk + cp artifacts/main-e2e-release-androidTest.apk/app-prod-release-androidTest.apk android/app/build/outputs/apk/androidTest/prod/release/app-prod-release-androidTest.apk + + - name: Repack Main APK + run: node scripts/repack.js + env: + PLATFORM: android + METAMASK_ENVIRONMENT: e2e + METAMASK_BUILD_TYPE: flask + IS_TEST: 'true' + E2E: 'true' + IGNORE_BOXLOGS_DEVELOPMENT: 'true' + GITHUB_CI: 'true' + CI: 'true' + NODE_OPTIONS: '--max-old-space-size=8192' + BRIDGE_USE_DEV_APIS: 'true' + RAMP_INTERNAL_BUILD: 'true' + SEEDLESS_ONBOARDING_ENABLED: 'true' + MM_NOTIFICATIONS_UI_ENABLED: 'true' + MM_SECURITY_ALERTS_API_ENABLED: 'true' + MM_REMOVE_GLOBAL_NETWORK_SELECTOR: 'true' + FEATURES_ANNOUNCEMENTS_ACCESS_TOKEN: ${{ secrets.FEATURES_ANNOUNCEMENTS_ACCESS_TOKEN }} + FEATURES_ANNOUNCEMENTS_SPACE_ID: ${{ secrets.FEATURES_ANNOUNCEMENTS_SPACE_ID }} + SEGMENT_WRITE_KEY_QA: ${{ secrets.SEGMENT_WRITE_KEY_QA }} + SEGMENT_WRITE_KEY_FLASK: ${{ secrets.SEGMENT_WRITE_KEY_FLASK }} + SEGMENT_PROXY_URL_QA: ${{ secrets.SEGMENT_PROXY_URL_QA }} + SEGMENT_PROXY_URL_FLASK: ${{ secrets.SEGMENT_PROXY_URL_FLASK }} + SEGMENT_DELETE_API_SOURCE_ID_QA: ${{ secrets.SEGMENT_DELETE_API_SOURCE_ID_QA }} + SEGMENT_DELETE_API_SOURCE_ID_FLASK: ${{ secrets.SEGMENT_DELETE_API_SOURCE_ID_FLASK }} + SEGMENT_REGULATIONS_ENDPOINT_QA: ${{ secrets.SEGMENT_REGULATIONS_ENDPOINT_QA }} + SEGMENT_REGULATIONS_ENDPOINT_FLASK: ${{ secrets.SEGMENT_REGULATIONS_ENDPOINT_FLASK }} + MM_SENTRY_DSN_TEST: ${{ secrets.MM_SENTRY_DSN_TEST }} + MM_SENTRY_AUTH_TOKEN: ${{ secrets.MM_SENTRY_AUTH_TOKEN }} + MAIN_IOS_GOOGLE_CLIENT_ID_UAT: ${{ secrets.MAIN_IOS_GOOGLE_CLIENT_ID_UAT }} + FLASK_IOS_GOOGLE_CLIENT_ID_PROD: ${{ secrets.FLASK_IOS_GOOGLE_CLIENT_ID_PROD }} + MAIN_IOS_GOOGLE_REDIRECT_URI_UAT: ${{ secrets.MAIN_IOS_GOOGLE_REDIRECT_URI_UAT }} + FLASK_IOS_GOOGLE_REDIRECT_URI_PROD: ${{ secrets.FLASK_IOS_GOOGLE_REDIRECT_URI_PROD }} + MAIN_ANDROID_APPLE_CLIENT_ID_UAT: ${{ secrets.MAIN_ANDROID_APPLE_CLIENT_ID_UAT }} + FLASK_ANDROID_APPLE_CLIENT_ID_PROD: ${{ secrets.FLASK_ANDROID_APPLE_CLIENT_ID_PROD }} + MAIN_ANDROID_GOOGLE_CLIENT_ID_UAT: ${{ secrets.MAIN_ANDROID_GOOGLE_CLIENT_ID_UAT }} + FLASK_ANDROID_GOOGLE_CLIENT_ID_PROD: ${{ secrets.FLASK_ANDROID_GOOGLE_CLIENT_ID_PROD }} + MAIN_ANDROID_GOOGLE_SERVER_CLIENT_ID_UAT: ${{ secrets.MAIN_ANDROID_GOOGLE_SERVER_CLIENT_ID_UAT }} + FLASK_ANDROID_GOOGLE_SERVER_CLIENT_ID_PROD: ${{ secrets.FLASK_ANDROID_GOOGLE_SERVER_CLIENT_ID_PROD }} + GOOGLE_SERVICES_B64_IOS: ${{ secrets.GOOGLE_SERVICES_B64_IOS }} + GOOGLE_SERVICES_B64_ANDROID: ${{ secrets.GOOGLE_SERVICES_B64_ANDROID }} + MM_INFURA_PROJECT_ID: ${{ secrets.MM_INFURA_PROJECT_ID }} + ANDROID_KEYSTORE_PATH: android/keystores/internalRelease.keystore + + - name: Prepare artifacts for upload + run: | + # Copy APKs to workspace root with correct names (no path structure) + cp android/app/build/outputs/apk/prod/release/app-prod-release.apk app-flask-release.apk + cp android/app/build/outputs/apk/androidTest/prod/release/app-prod-release-androidTest.apk app-flask-release-androidTest.apk + + - name: Upload Repacked APK + uses: actions/upload-artifact@v4 + with: + name: flask-e2e-release.apk + path: app-flask-release.apk + retention-days: 1 + + - name: Upload Test APK + uses: actions/upload-artifact@v4 + with: + name: flask-e2e-release-androidTest.apk + path: app-flask-release-androidTest.apk + retention-days: 1 + flask-android-smoke: + needs: [repack-android-flask-apps] strategy: matrix: split: [1, 2, 3] diff --git a/app/components/UI/Perps/Views/PerpsMarketDetailsView/PerpsMarketDetailsView.test.tsx b/app/components/UI/Perps/Views/PerpsMarketDetailsView/PerpsMarketDetailsView.test.tsx index 9c4153ecc54..f6a41c3df0d 100644 --- a/app/components/UI/Perps/Views/PerpsMarketDetailsView/PerpsMarketDetailsView.test.tsx +++ b/app/components/UI/Perps/Views/PerpsMarketDetailsView/PerpsMarketDetailsView.test.tsx @@ -182,13 +182,32 @@ jest.mock('react-redux', () => ({ useSelector: jest.fn(), })); +// Mock usePerpsConnection hook directly to ensure all hooks that import it get the mock +jest.mock('../../hooks/usePerpsConnection', () => ({ + usePerpsConnection: () => ({ + isConnected: true, + isConnecting: false, + isInitialized: true, + error: null, + connect: jest.fn(), + disconnect: jest.fn(), + resetError: jest.fn(), + reconnectWithNewContext: jest.fn(), + }), +})); + jest.mock('../../providers/PerpsConnectionProvider', () => ({ PerpsConnectionProvider: ({ children }: { children: React.ReactNode }) => children, usePerpsConnection: () => ({ isConnected: true, isConnecting: false, + isInitialized: true, error: null, + connect: jest.fn(), + disconnect: jest.fn(), + resetError: jest.fn(), + reconnectWithNewContext: jest.fn(), }), })); @@ -266,12 +285,28 @@ jest.mock('../../hooks/usePerpsEventTracking', () => ({ })), })); +jest.mock('../../hooks/usePerpsPrices', () => ({ + usePerpsPrices: jest.fn(() => ({})), +})); + +jest.mock('../../hooks/useIsPriceDeviatedAboveThreshold', () => ({ + useIsPriceDeviatedAboveThreshold: jest.fn(() => ({ + isDeviatedAboveThreshold: false, + isLoading: false, + })), +})); + jest.mock('../../hooks', () => ({ usePerpsLiveAccount: () => mockUsePerpsAccount(), usePerpsConnection: () => ({ isConnected: true, isConnecting: false, + isInitialized: true, error: null, + connect: jest.fn(), + disconnect: jest.fn(), + resetError: jest.fn(), + reconnectWithNewContext: jest.fn(), }), usePerpsOpenOrders: () => ({ orders: [], diff --git a/app/components/UI/Perps/Views/PerpsMarketDetailsView/PerpsMarketDetailsView.tsx b/app/components/UI/Perps/Views/PerpsMarketDetailsView/PerpsMarketDetailsView.tsx index ddfd394cea7..bc5e2fca0ba 100644 --- a/app/components/UI/Perps/Views/PerpsMarketDetailsView/PerpsMarketDetailsView.tsx +++ b/app/components/UI/Perps/Views/PerpsMarketDetailsView/PerpsMarketDetailsView.tsx @@ -68,6 +68,7 @@ import { usePerpsDataMonitor, type DataMonitorParams, } from '../../hooks/usePerpsDataMonitor'; +import { useIsPriceDeviatedAboveThreshold } from '../../hooks/useIsPriceDeviatedAboveThreshold'; import { usePerpsMeasurement } from '../../hooks/usePerpsMeasurement'; import { usePerpsLiveAccount, @@ -81,6 +82,7 @@ import PerpsPositionCard from '../../components/PerpsPositionCard'; import PerpsMarketStatisticsCard from '../../components/PerpsMarketStatisticsCard'; import type { PerpsTooltipContentKey } from '../../components/PerpsBottomSheetTooltip/PerpsBottomSheetTooltip.types'; import PerpsOICapWarning from '../../components/PerpsOICapWarning'; +import PerpsPriceDeviationWarning from '../../components/PerpsPriceDeviationWarning'; import PerpsNotificationTooltip from '../../components/PerpsNotificationTooltip'; import PerpsNavigationCard, { type NavigationItem, @@ -295,6 +297,12 @@ const PerpsMarketDetailsView: React.FC = () => { // Check if market is at open interest cap const { isAtCap: isAtOICap } = usePerpsOICap(market?.symbol); + // Check if trading is halted due to price deviation + const { + isDeviatedAboveThreshold: isTradingHalted, + isLoading: isLoadingTradingHalted, + } = useIsPriceDeviatedAboveThreshold(market?.symbol); + // Handle data-driven monitoring when coming from order success // Clear monitoringIntent after processing to allow fresh monitoring next time const handleDataDetected = useCallback(() => { @@ -894,21 +902,19 @@ const PerpsMarketDetailsView: React.FC = () => { )} {hasHistoricalData ? ( - <> - - + ) : ( = () => { onMorePress={handleMorePress} testID={`${PerpsMarketDetailsViewSelectorsIDs.CONTAINER}-candle-period-selector`} /> + + {/* Price Deviation Warning - Shows when price has deviated too much from spot price */} + {market?.symbol && isTradingHalted && !isLoadingTradingHalted && ( + + )} {/* OI Cap Warning - Shows when market is at capacity */} @@ -1035,7 +1048,7 @@ const PerpsMarketDetailsView: React.FC = () => { {/* Fixed Actions Footer */} - {(hasAddFundsButton || hasLongShortButtons) && ( + {(hasAddFundsButton || hasLongShortButtons) && !isTradingHalted && ( {hasAddFundsButton && ( diff --git a/app/components/UI/Perps/Views/PerpsOrderView/PerpsOrderView.tsx b/app/components/UI/Perps/Views/PerpsOrderView/PerpsOrderView.tsx index 050f4eaf113..bfc218f2b3b 100644 --- a/app/components/UI/Perps/Views/PerpsOrderView/PerpsOrderView.tsx +++ b/app/components/UI/Perps/Views/PerpsOrderView/PerpsOrderView.tsx @@ -27,6 +27,7 @@ import ButtonSemantic, { import Button, { ButtonSize, ButtonVariants, + ButtonWidthTypes, } from '../../../../../component-library/components/Buttons/Button'; import Icon, { IconColor, @@ -102,6 +103,9 @@ import { import { usePerpsEventTracking } from '../../hooks/usePerpsEventTracking'; import { usePerpsMeasurement } from '../../hooks/usePerpsMeasurement'; import { usePerpsOICap } from '../../hooks/usePerpsOICap'; +import { usePerpsABTest } from '../../utils/abTesting/usePerpsABTest'; +import { BUTTON_COLOR_TEST } from '../../utils/abTesting/tests'; +import { selectPerpsButtonColorTestVariant } from '../../selectors/featureFlags'; import { formatPerpsFiat, PRICE_RANGES_MINIMAL_VIEW, @@ -253,6 +257,12 @@ const PerpsOrderViewContentBase: React.FC = ({ // Check if market is at OI cap (zero network overhead - uses existing webData2 subscription) const { isAtCap: isAtOICap } = usePerpsOICap(orderForm.asset); + // A/B Testing: Button color test (TAT-1937) + const { variantName: buttonColorVariant } = usePerpsABTest({ + test: BUTTON_COLOR_TEST, + featureFlagSelector: selectPerpsButtonColorTestVariant, + }); + // Markets data for navigation const { markets } = usePerpsMarkets(); @@ -1291,26 +1301,44 @@ const PerpsOrderViewContentBase: React.FC = ({ )} - - {placeOrderLabel} - + {buttonColorVariant === 'monochrome' ? ( +