diff --git a/.github/CODEOWNERS b/.github/CODEOWNERS index 6eac88a5261..f1d6f77fc2c 100644 --- a/.github/CODEOWNERS +++ b/.github/CODEOWNERS @@ -334,6 +334,7 @@ tests/tools/ @MetaMask/qa tests/websocket/ @MetaMask/qa # QA Team - CI +.github/guidelines/E2E_DECISION_TREE.md @MetaMask/qa .github/actions/smart-e2e-selection/ @MetaMask/qa .github/workflows/ai-pr-risk-analysis.yml @MetaMask/qa .github/workflows/auto-label-not-ready-for-e2e.yml @MetaMask/qa diff --git a/.github/actions/setup-e2e-env/action.yml b/.github/actions/setup-e2e-env/action.yml index 227deaba598..0e54460e06e 100644 --- a/.github/actions/setup-e2e-env/action.yml +++ b/.github/actions/setup-e2e-env/action.yml @@ -281,6 +281,7 @@ runs: with: path: | node_modules + .yarn/install-state.gz key: ${{ inputs.cache-prefix }}-yarn-${{ inputs.platform }}-${{ runner.os }}-${{ hashFiles('yarn.lock') }} - name: Install JavaScript dependencies with retry diff --git a/.github/guidelines/E2E_DECISION_TREE.md b/.github/guidelines/E2E_DECISION_TREE.md index 96441d8f46b..53a9aebb4d7 100644 --- a/.github/guidelines/E2E_DECISION_TREE.md +++ b/.github/guidelines/E2E_DECISION_TREE.md @@ -10,7 +10,7 @@ flowchart TD GR -->|PR label: skip-e2e| HS[No E2E] GR -->|PR label: pr-not-ready-for-e2e| L2[No E2E] L2 -->|ignorable-only changes| NoBlock[No merge block] - L2 -->|non-ignorable changes| Skip2[Merge blocked] + L2 -->|non-ignorable changes| Skip2[⛔️ Merge blocked] GR -->|PR ignorable-only changes| Ignorable[No E2E] GR -->|PR has Android-only changes| Android[Android Build + Tests needed] GR -->|PR has iOS-only changes| iOS[iOS Build + Test needed] @@ -41,9 +41,15 @@ Runs only when all of the following are true: - No hard E2E skip signal (label `skip-e2e`) - No `skip-smart-e2e-selection` label +## (Exceptional) skip builds and all E2E tests + +- Label `skip-e2e` can be added to the PR to skip E2E tests (and builds) in case of infra issues. +- Using this label should be exceptional in case of CI friction and urgencies. Verify new changes and regressions manually before merging. + ## E2E flakiness detection in PRs Flakiness detection is applied to modified E2E test files in PRs: - Modified E2E test files run twice -- It applies to existing test files as well as new test files +- It applies to existing test files as well as new test files added in the PR +- It can be disabled by adding the label `skip-e2e-flakiness-detection`. Useful when making large refactors or when changes don't pose flakiness risk. diff --git a/.github/guidelines/LABELING_GUIDELINES.md b/.github/guidelines/LABELING_GUIDELINES.md index c93d7d4d976..4819b24e8f7 100644 --- a/.github/guidelines/LABELING_GUIDELINES.md +++ b/.github/guidelines/LABELING_GUIDELINES.md @@ -33,7 +33,7 @@ Using any of these labels should be exceptional in case of CI friction and urgen - **skip-sonar-cloud**: The PR will be merged without running SonarCloud checks. - **skip-e2e**: The PR will be merged without running E2E tests. -- **skip-e2e-quality-gate**: This label will disable the default test retries for E2E test files modified in a PR. Useful when making large refactors or when changes don't pose flakiness risk. +- **skip-e2e-flakiness-detection**: This label will disable the default test retries for E2E test files modified in a PR. Useful when making large refactors or when changes don't pose flakiness risk. ### Skip Smart E2E Selection diff --git a/.github/rules/filter-rules.yml b/.github/rules/filter-rules.yml index d1928e2df4f..b3e9d129805 100644 --- a/.github/rules/filter-rules.yml +++ b/.github/rules/filter-rules.yml @@ -24,6 +24,10 @@ low_level_test_files: &low_level_test_files - '**/*.stories.*' - '**/*.snap' +# LOCALE TRANSLATION FILES +locale_translation_files: &locale_translation_files + - 'locales/languages/**/*.json' + # CONFIG FILES config_files: &config_files - '.eslint*' @@ -47,6 +51,7 @@ e2e_ignorable: - *documentation_files - *asset_files - *low_level_test_files + - *locale_translation_files - *config_files - *ci_files @@ -74,6 +79,7 @@ android_or_ignorable: - *documentation_files - *asset_files - *low_level_test_files + - *locale_translation_files - *config_files - *ci_files @@ -82,6 +88,7 @@ ios_or_ignorable: - *documentation_files - *asset_files - *low_level_test_files + - *locale_translation_files - *config_files - *ci_files diff --git a/.github/scripts/e2e-split-tags-shards.mjs b/.github/scripts/e2e-split-tags-shards.mjs index 590fcb28af0..b178e0f5b28 100644 --- a/.github/scripts/e2e-split-tags-shards.mjs +++ b/.github/scripts/e2e-split-tags-shards.mjs @@ -108,9 +108,9 @@ async function shouldSkipFlakinessDetection() { ); const labels = data?.repository?.pullRequest?.labels?.nodes || []; - const labelFound = labels.some((l) => String(l?.name).toLowerCase() === 'skip-e2e-quality-gate'); + const labelFound = labels.some((l) => String(l?.name).toLowerCase() === 'skip-e2e-flakiness-detection'); if (labelFound) { - console.log('⏭️ Found "skip-e2e-quality-gate" label → SKIPPING flakiness detection'); + console.log('⏭️ Found "skip-e2e-flakiness-detection" label → SKIPPING flakiness detection'); } return labelFound; } catch (e) { diff --git a/.github/workflows/auto-label-not-ready-for-e2e.yml b/.github/workflows/auto-label-not-ready-for-e2e.yml index 58ea09f5dd3..df0e4bf2b2a 100644 --- a/.github/workflows/auto-label-not-ready-for-e2e.yml +++ b/.github/workflows/auto-label-not-ready-for-e2e.yml @@ -1,3 +1,5 @@ +# Automatically applies the 'pr-not-ready-for-e2e' label to newly opened PRs, +# but only if the PR is opened between 13:00 and 17:00 UTC (15:00–19:00 CEST). name: Auto-apply pr-not-ready-for-e2e label on: @@ -16,7 +18,18 @@ jobs: permissions: pull-requests: write steps: + - name: Check current UTC hour + id: time-check + run: | + HOUR=$(date -u +%H) + echo "Current UTC hour: $HOUR" + if [[ $HOUR -ge 13 && $HOUR -lt 17 ]]; then + echo "in_window=true" >> "$GITHUB_OUTPUT" + else + echo "in_window=false" >> "$GITHUB_OUTPUT" + fi - name: Add pr-not-ready-for-e2e label + if: steps.time-check.outputs.in_window == 'true' env: GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} GH_REPO: ${{ github.repository }} diff --git a/.github/workflows/build-rc-auto.yml b/.github/workflows/build-rc-auto.yml index 8f9ca3d5f77..95a6dba90f1 100644 --- a/.github/workflows/build-rc-auto.yml +++ b/.github/workflows/build-rc-auto.yml @@ -151,6 +151,15 @@ jobs: cache: yarn - name: Install dependencies run: yarn install --immutable + + - name: Download build environment artifacts + continue-on-error: true + uses: actions/download-artifact@v4 + with: + pattern: build-env-main-rc-* + path: build-env-artifacts + merge-multiple: false + - name: Post RC Build Comment with Test Plan run: node -r esbuild-register scripts/build-announce/index.ts timeout-minutes: 8 diff --git a/.github/workflows/build.yml b/.github/workflows/build.yml index adf4bcb4a3d..fdf959acbb5 100644 --- a/.github/workflows/build.yml +++ b/.github/workflows/build.yml @@ -305,6 +305,8 @@ jobs: eval "$(node scripts/apply-build-config.js ${{ inputs.build_name }} --export)" # Persist to GITHUB_ENV so later steps (e.g. Build) see CONFIGURATION, IS_SIM_BUILD, etc. node scripts/apply-build-config.js ${{ inputs.build_name }} --export-github-env >> "$GITHUB_ENV" + # Generate build-env.json capturing actual env values used in this build (non-critical) + node scripts/apply-build-config.js ${{ inputs.build_name }} --write-build-env || true - name: Validate secrets env: @@ -497,6 +499,14 @@ jobs: path: ${{ steps.rename.outputs.android_sourcemap_dir }} if-no-files-found: warn + - name: Upload build environment JSON + if: success() + uses: actions/upload-artifact@v4 + with: + name: build-env-${{ inputs.build_name }}-${{ matrix.platform }} + path: build-env.json + if-no-files-found: warn + # Single fan-in job so workflow_call outputs work with matrix `build` (matrix jobs cannot feed workflow outputs directly). emit-build-metadata: name: Emit build metadata diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 8a83b8d4de9..96719067513 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -510,11 +510,20 @@ jobs: if: ${{ !cancelled() && github.event_name != 'merge_group' }} steps: - uses: actions/checkout@v6 + - name: Restore node_modules cache + id: cache-node-modules + uses: actions/cache@v4 + with: + path: | + node_modules + .yarn/install-state.gz + key: ${{ runner.os }}-node-modules-${{ hashFiles('yarn.lock') }} - uses: actions/setup-node@v6 with: node-version-file: '.nvmrc' cache: yarn - name: Install Yarn dependencies with retry + if: steps.cache-node-modules.outputs.cache-hit != 'true' uses: nick-fields/retry@ce71cc2ab81d554ebbe88c79ab5975992d79ba08 #v3.0.2 with: timeout_minutes: 10 @@ -622,11 +631,20 @@ jobs: shard: [1, 2] steps: - uses: actions/checkout@v6 + - name: Restore node_modules cache + id: cache-node-modules + uses: actions/cache@v4 + with: + path: | + node_modules + .yarn/install-state.gz + key: ${{ runner.os }}-node-modules-${{ hashFiles('yarn.lock') }} - uses: actions/setup-node@v6 with: node-version-file: '.nvmrc' cache: yarn - name: Install Yarn dependencies with retry + if: steps.cache-node-modules.outputs.cache-hit != 'true' uses: nick-fields/retry@ce71cc2ab81d554ebbe88c79ab5975992d79ba08 #v3.0.2 with: timeout_minutes: 10 @@ -981,6 +999,8 @@ jobs: needs: - get_requirements - all-jobs-pass + - build-android-apks + - build-ios-apps - e2e-smoke-tests-android - e2e-smoke-tests-ios env: @@ -1004,22 +1024,23 @@ jobs: exit 1 fi - # Check E2E jobs only if they should have run + # Check E2E build + smoke results only if E2E should have run. + # 'skipped' is acceptable — covers merge_group, fork PRs, ignorable-only changes, + # platform-only PRs, and AI selection returning zero tags. + # 'failure'/'cancelled' on any of build or smoke must block merge. if [[ "${{ needs.get_requirements.outputs.skip_e2e }}" != "true" ]]; then - # Accept both 'success' and 'skipped' as valid results - # 'skipped' occurs during merge_group events or when jobs are intentionally skipped - # Only fail on 'failure' or 'cancelled' - ANDROID_RESULT="${{ needs.e2e-smoke-tests-android.result }}" - if [[ "$ANDROID_RESULT" == "failure" ]] || [[ "$ANDROID_RESULT" == "cancelled" ]]; then - echo "Android E2E tests failed (result: $ANDROID_RESULT)" - exit 1 - fi - - IOS_RESULT="${{ needs.e2e-smoke-tests-ios.result }}" - if [[ "$IOS_RESULT" == "failure" ]] || [[ "$IOS_RESULT" == "cancelled" ]]; then - echo "iOS E2E tests failed (result: $IOS_RESULT)" - exit 1 - fi + for entry in \ + "build-android-apks:${{ needs.build-android-apks.result }}" \ + "e2e-smoke-tests-android:${{ needs.e2e-smoke-tests-android.result }}" \ + "build-ios-apps:${{ needs.build-ios-apps.result }}" \ + "e2e-smoke-tests-ios:${{ needs.e2e-smoke-tests-ios.result }}"; do + name="${entry%%:*}" + result="${entry#*:}" + if [[ "$result" == "failure" ]] || [[ "$result" == "cancelled" ]]; then + echo "::error::Required E2E job '$name' did not succeed (result: $result)" + exit 1 + fi + done fi echo "All required jobs passed" diff --git a/.github/workflows/prod-build-env-notify.yml b/.github/workflows/prod-build-env-notify.yml new file mode 100644 index 00000000000..c3e631258bb --- /dev/null +++ b/.github/workflows/prod-build-env-notify.yml @@ -0,0 +1,122 @@ +############################################################################################## +# +# Production Build Environment Notification +# +# Automatically triggers when production builds complete. +# Downloads build-env.json artifact and extracts environment values. +# TODO: Post to Slack (follow-up PR) +# +############################################################################################## +name: Prod Build Env Notify + +on: + workflow_run: + workflows: ["Runway iOS Production", "Runway Android Production"] + types: [completed] + +jobs: + notify-env: + name: Post Environment Info + runs-on: ubuntu-latest + if: ${{ github.event.workflow_run.conclusion == 'success' }} + steps: + - uses: actions/checkout@v6 + + - uses: actions/setup-node@v6 + with: + node-version-file: '.nvmrc' + + - name: Download build-env artifact from triggering workflow + continue-on-error: true + id: download-artifact + uses: actions/download-artifact@v6 + with: + name: build-env-main-prod-${{ github.event.workflow_run.name == 'Runway iOS Production' && 'ios' || 'android' }} + path: build-env-artifacts + run-id: ${{ github.event.workflow_run.id }} + github-token: ${{ secrets.GITHUB_TOKEN }} + + - name: Extract and display environment values + if: steps.download-artifact.outcome == 'success' + id: extract-env + run: | + JSON_FILE="build-env-artifacts/build-env.json" + + if [ ! -f "$JSON_FILE" ]; then + echo "ERROR: build-env.json not found" + exit 1 + fi + + echo "=== Build Environment Values ===" + cat "$JSON_FILE" + echo "" + + # Extract values + BUILD_NAME=$(jq -r '.buildName' "$JSON_FILE") + METAMASK_ENV=$(jq -r '.env.METAMASK_ENVIRONMENT' "$JSON_FILE") + BUILD_TYPE=$(jq -r '.env.METAMASK_BUILD_TYPE' "$JSON_FILE") + REWARDS_URL=$(jq -r '.env.REWARDS_API_URL' "$JSON_FILE") + PORTFOLIO_URL=$(jq -r '.env.MM_PORTFOLIO_URL' "$JSON_FILE") + RAMPS_ENV=$(jq -r '.env.RAMPS_ENVIRONMENT' "$JSON_FILE") + + # Map to Remote Feature Flag values + case "$METAMASK_ENV" in + production) REMOTE_FF_ENV="prod" ;; + rc) REMOTE_FF_ENV="rc" ;; + beta) REMOTE_FF_ENV="beta" ;; + test|e2e) REMOTE_FF_ENV="test" ;; + exp) REMOTE_FF_ENV="exp" ;; + *) REMOTE_FF_ENV="dev" ;; + esac + + case "$BUILD_TYPE" in + flask) REMOTE_FF_DIST="flask" ;; + *) REMOTE_FF_DIST="main" ;; + esac + + # Output for next steps + { + echo "build_name=$BUILD_NAME" + echo "environment=$METAMASK_ENV" + echo "build_type=$BUILD_TYPE" + echo "remote_ff_env=$REMOTE_FF_ENV" + echo "remote_ff_dist=$REMOTE_FF_DIST" + echo "rewards_url=$REWARDS_URL" + echo "portfolio_url=$PORTFOLIO_URL" + echo "ramps_env=$RAMPS_ENV" + } >> "$GITHUB_OUTPUT" + + echo "" + echo "=== Extracted Values ===" + echo "Environment: $METAMASK_ENV" + echo "Build Type: $BUILD_TYPE" + echo "Remote Feature Flag Env: $REMOTE_FF_ENV" + echo "Remote Feature Flag Distribution: $REMOTE_FF_DIST" + echo "Rewards API URL: $REWARDS_URL" + echo "MM_PORTFOLIO_URL: $PORTFOLIO_URL" + echo "Ramps Environment: $RAMPS_ENV" + + - name: Post to Slack + if: steps.download-artifact.outcome == 'success' + env: + SLACK_BOT_TOKEN: ${{ secrets.SLACK_BOT_TOKEN }} + PLATFORM: ${{ github.event.workflow_run.name == 'Runway iOS Production' && 'iOS' || 'Android' }} + ENVIRONMENT: ${{ steps.extract-env.outputs.environment }} + BUILD_TYPE: ${{ steps.extract-env.outputs.build_type }} + REMOTE_FF_ENV: ${{ steps.extract-env.outputs.remote_ff_env }} + REMOTE_FF_DIST: ${{ steps.extract-env.outputs.remote_ff_dist }} + REWARDS_URL: ${{ steps.extract-env.outputs.rewards_url }} + PORTFOLIO_URL: ${{ steps.extract-env.outputs.portfolio_url }} + RAMPS_ENV: ${{ steps.extract-env.outputs.ramps_env }} + WORKFLOW_URL: ${{ github.event.workflow_run.html_url }} + run: | + echo "TODO: Post to Slack" + echo "Platform: $PLATFORM" + echo "Environment: $ENVIRONMENT" + echo "Build Type: $BUILD_TYPE" + echo "Remote FF Env: $REMOTE_FF_ENV" + echo "Remote FF Distribution: $REMOTE_FF_DIST" + echo "Rewards URL: $REWARDS_URL" + echo "Portfolio URL: $PORTFOLIO_URL" + echo "Ramps Environment: $RAMPS_ENV" + echo "Workflow URL: $WORKFLOW_URL" diff --git a/.github/workflows/rerun-ci-on-skipped-e2e-labels.yml b/.github/workflows/rerun-ci-on-skipped-e2e-labels.yml index 2986f7fae76..24cea0920f3 100644 --- a/.github/workflows/rerun-ci-on-skipped-e2e-labels.yml +++ b/.github/workflows/rerun-ci-on-skipped-e2e-labels.yml @@ -14,7 +14,7 @@ jobs: if: >- github.event.label.name == 'skip-smart-e2e-selection' || github.event.label.name == 'skip-e2e' || - github.event.label.name == 'skip-e2e-quality-gate' || + github.event.label.name == 'skip-e2e-flakiness-detection' || github.event.label.name == 'pr-not-ready-for-e2e' runs-on: ubuntu-latest permissions: @@ -92,5 +92,8 @@ jobs: run: | RUN_ID="${{ steps.find.outputs.run_id }}" echo "Re-running workflow $RUN_ID..." - gh run rerun "$RUN_ID" --repo "$REPO" - echo "CI workflow re-triggered successfully" + if gh run rerun "$RUN_ID" --repo "$REPO"; then + echo "CI workflow re-triggered successfully" + else + echo "Rerun not possible (run may not be in a retriable state)" + fi diff --git a/.gitignore b/.gitignore index f9bc9b242ad..060f173e07b 100644 --- a/.gitignore +++ b/.gitignore @@ -196,3 +196,6 @@ runway-artifacts/ release-test-plan.json release-delta.json release-signoffs.json + +# Build environment validation +build-env.json diff --git a/app/components/UI/Predict/providers/polymarket/constants.ts b/app/components/UI/Predict/providers/polymarket/constants.ts index 0d6838c7515..01fe31fb2ae 100644 --- a/app/components/UI/Predict/providers/polymarket/constants.ts +++ b/app/components/UI/Predict/providers/polymarket/constants.ts @@ -101,10 +101,10 @@ export const COLLATERAL_OFFRAMP_ADDRESS = '0x2957922Eb93258b93368531d39fAcCA3B4dC5854'; export const CTF_COLLATERAL_ADAPTER_ADDRESS = - '0xADa100874d00e3331D00F2007a9c336a65009718'; + '0xAdA100Db00Ca00073811820692005400218FcE1f'; export const NEG_RISK_CTF_COLLATERAL_ADAPTER_ADDRESS = - '0xAdA200001000ef00D07553cEE7006808F895c6F1'; + '0xadA2005600Dec949baf300f4C6120000bDB6eAab'; export const POLYGON_USDC_CAIP_ASSET_ID = `${POLYGON_MAINNET_CAIP_CHAIN_ID}/erc20:${MATIC_CONTRACTS.collateral}` as const; diff --git a/scripts/apply-build-config.js b/scripts/apply-build-config.js index 275929815b2..6b7a8e64c14 100755 --- a/scripts/apply-build-config.js +++ b/scripts/apply-build-config.js @@ -18,6 +18,7 @@ const path = require('path'); const yaml = require('js-yaml'); const BUILDS_PATH = path.join(__dirname, '../builds.yml'); +const BUILD_ENV_PATH = path.join(__dirname, '../build-env.json'); function loadConfig(buildName) { if (!fs.existsSync(BUILDS_PATH)) { @@ -106,16 +107,68 @@ function exportForGitHubEnv(buildName) { return lines.join('\n'); } +/** + * Write build environment to JSON file for display in PR comments. + * This captures the actual env values used during build time. + */ +function writeBuildEnvJson(buildName) { + const config = loadConfig(buildName); + + const envVarsToCapture = [ + 'METAMASK_ENVIRONMENT', + 'METAMASK_BUILD_TYPE', + 'REWARDS_API_URL', + 'MM_PORTFOLIO_URL', + 'RAMPS_ENVIRONMENT', + 'IS_TEST', + // Additional env vars for full environment info + 'PORTFOLIO_API_URL', + 'SECURITY_ALERTS_API_URL', + 'DECODING_API_URL', + 'AUTH_SERVICE_URL', + 'DIGEST_API_URL', + 'SOCIAL_API_URL', + 'BAANX_API_URL', + 'RAMP_DEV_BUILD', + 'BRIDGE_USE_DEV_APIS', + 'RAMP_INTERNAL_BUILD', + ]; + + const buildEnv = { + buildName, + buildTime: new Date().toISOString(), + env: {}, + }; + + if (config.env) { + envVarsToCapture.forEach((key) => { + if (config.env[key] !== undefined) { + buildEnv.env[key] = String(config.env[key]); + } + }); + } + + if (config.code_fencing) { + buildEnv.codeFencing = config.code_fencing; + } + + fs.writeFileSync(BUILD_ENV_PATH, JSON.stringify(buildEnv, null, 2)); + console.log(`Wrote build environment to ${BUILD_ENV_PATH}`); + + return buildEnv; +} + // CLI if (require.main === module) { const args = process.argv.slice(2); const buildName = args.find((a) => !a.startsWith('--')); const exportMode = args.includes('--export'); const exportGitHubEnvMode = args.includes('--export-github-env'); + const writeBuildEnvMode = args.includes('--write-build-env'); if (!buildName) { console.error( - 'Usage: node apply-build-config.js [--export | --export-github-env]', + 'Usage: node apply-build-config.js [--export | --export-github-env | --write-build-env]', ); console.error('Example: node apply-build-config.js main-prod'); process.exit(1); @@ -126,12 +179,14 @@ if (require.main === module) { console.log(exportForGitHubEnv(buildName)); } else if (exportMode) { console.log(exportForShell(buildName)); + } else if (writeBuildEnvMode) { + writeBuildEnvJson(buildName); } else { applyConfig(buildName); - console.log(`✅ Applied config for ${buildName}`); + console.log(`Applied config for ${buildName}`); } } catch (error) { - console.error(`❌ ${error.message}`); + console.error(`Error: ${error.message}`); process.exit(1); } } @@ -141,4 +196,5 @@ module.exports = { applyConfig, exportForShell, exportForGitHubEnv, + writeBuildEnvJson, }; diff --git a/scripts/build-announce/env-validation-section.ts b/scripts/build-announce/env-validation-section.ts new file mode 100644 index 00000000000..4309868d5bd --- /dev/null +++ b/scripts/build-announce/env-validation-section.ts @@ -0,0 +1,141 @@ +/** + * Environment Section Builder + * + * Builds the markdown section displaying build environment values + * to be included in the RC build comment. + */ + +import type { EnvValidationResult } from './types'; + +/** + * Maps METAMASK_ENVIRONMENT to Remote Feature Flag Env display value + */ +function getRemoteFFEnv(env: string | undefined): string { + switch (env) { + case 'production': + return 'prod'; + case 'rc': + return 'rc'; + case 'beta': + return 'beta'; + case 'test': + case 'e2e': + return 'test'; + case 'exp': + return 'exp'; + case 'dev': + default: + return 'dev'; + } +} + +/** + * Maps METAMASK_BUILD_TYPE to Remote Feature Flag Distribution display value + */ +function getRemoteFFDistribution(buildType: string | undefined): string { + switch (buildType) { + case 'flask': + return 'flask'; + case 'main': + default: + return 'main'; + } +} + +/** + * Builds the environment section for the PR comment + * Shows actual environment values similar to "About MetaMask" screen + */ +export function buildEnvValidationSection( + androidResult?: EnvValidationResult, + iosResult?: EnvValidationResult, +): string { + // If no results, return empty + if (!androidResult && !iosResult) { + return ''; + } + + const lines: string[] = []; + + lines.push('### :shield: Build Environment\n'); + + // Use one result to display (they should be identical for same build config) + const result = androidResult || iosResult; + if (!result) return ''; + + const env = result.extractedValues.METAMASK_ENVIRONMENT ?? '—'; + const buildType = result.extractedValues.METAMASK_BUILD_TYPE ?? '—'; + const rewardsUrl = result.extractedValues.REWARDS_API_URL ?? '—'; + const portfolioUrl = result.extractedValues.MM_PORTFOLIO_URL ?? '—'; + const rampsEnv = result.extractedValues.RAMPS_ENVIRONMENT ?? '—'; + + // Main environment info table (like About MetaMask screen) + lines.push('| Setting | Value |'); + lines.push('| :--- | :--- |'); + lines.push(`| **Environment** | \`${env}\` |`); + lines.push(`| **Build Type** | \`${buildType}\` |`); + lines.push(`| **Remote Feature Flag Env** | \`${getRemoteFFEnv(env)}\` |`); + lines.push(`| **Remote Feature Flag Distribution** | \`${getRemoteFFDistribution(buildType)}\` |`); + lines.push(`| **Ramps Environment** | \`${rampsEnv}\` |`); + lines.push(''); + + // Detailed info in collapsible section + lines.push('
'); + lines.push('API URLs & Details\n'); + + lines.push('| API | URL |'); + lines.push('| :--- | :--- |'); + lines.push(`| Rewards API | \`${rewardsUrl}\` |`); + lines.push(`| Portfolio API | \`${portfolioUrl}\` |`); + + // Add more URLs if available + const portfolioApiUrl = result.extractedValues.PORTFOLIO_API_URL; + if (portfolioApiUrl) { + lines.push(`| Portfolio API (alt) | \`${portfolioApiUrl}\` |`); + } + + const securityAlertsUrl = result.extractedValues.SECURITY_ALERTS_API_URL; + if (securityAlertsUrl) { + lines.push(`| Security Alerts API | \`${securityAlertsUrl}\` |`); + } + + lines.push('\n**Build Flags:**\n'); + lines.push(`- Build Name: \`${result.buildName}\``); + lines.push(`- IS_TEST: \`${result.extractedValues.IS_TEST ?? 'false'}\``); + + const rampDevBuild = result.extractedValues.RAMP_DEV_BUILD; + if (rampDevBuild) { + lines.push(`- RAMP_DEV_BUILD: \`${rampDevBuild}\``); + } + + const bridgeDevApis = result.extractedValues.BRIDGE_USE_DEV_APIS; + if (bridgeDevApis) { + lines.push(`- BRIDGE_USE_DEV_APIS: \`${bridgeDevApis}\``); + } + + lines.push('\n
\n'); + + return lines.join('\n'); +} + +/** + * Builds a failure section when environment values could not be extracted + */ +export function buildEnvValidationFailureSection(error: string): string { + return `### :shield: Build Environment + +**Status:** :warning: Not available + +
+Details + +Environment values could not be extracted: ${error} + +This may happen if: +- Build artifacts are not available +- build-env.json was not generated during the build + +
+ +`; +} diff --git a/scripts/build-announce/index.ts b/scripts/build-announce/index.ts index df9ced66c31..ec242736aba 100644 --- a/scripts/build-announce/index.ts +++ b/scripts/build-announce/index.ts @@ -1,5 +1,7 @@ // RC Build Announce - Posts RC build comments to GitHub PRs with build links and AI test plan +import { existsSync, readdirSync } from 'fs'; +import { join } from 'path'; import { Octokit } from '@octokit/rest'; import { RC_BUILD_COMMENT_MARKER, @@ -16,7 +18,12 @@ import { buildTestPlanSection, buildTestPlanFailureSection, } from './test-plan-section'; -import type { BuildInfo, TestPlanResult } from './types'; +import { + buildEnvValidationSection, + buildEnvValidationFailureSection, +} from './env-validation-section'; +import { validateEnv } from './validate-env'; +import type { BuildInfo, TestPlanResult, EnvValidationResult } from './types'; /** * Builds the build links section of the comment @@ -70,12 +77,80 @@ function buildMoreInfoSection(buildInfo: BuildInfo): string { `; } +/** + * Look for build-env.json artifacts and extract environment values + */ +function performEnvValidation(): { + androidResult?: EnvValidationResult; + iosResult?: EnvValidationResult; + error?: string; +} { + const artifactsDir = 'build-env-artifacts'; + + if (!existsSync(artifactsDir)) { + console.log('No build-env-artifacts directory found, skipping env extraction'); + return {}; + } + + const results: { + androidResult?: EnvValidationResult; + iosResult?: EnvValidationResult; + error?: string; + } = {}; + + try { + // Check for flat path first (in case merge-multiple flattens all artifacts) + const flatPath = join(artifactsDir, 'build-env.json'); + if (existsSync(flatPath)) { + console.log(`Found build-env.json at ${flatPath}`); + const result = validateEnv(flatPath); + results.androidResult = result; + return results; + } + + // Otherwise look in subdirectories + const dirs = readdirSync(artifactsDir, { withFileTypes: true }); + + for (const dir of dirs) { + if (!dir.isDirectory()) continue; + + const buildEnvPath = join(artifactsDir, dir.name, 'build-env.json'); + + if (!existsSync(buildEnvPath)) { + continue; + } + + console.log(`Found build-env.json at ${buildEnvPath}`); + + // Determine platform from directory name + const platform = dir.name.includes('android') ? 'android' : 'ios'; + const result = validateEnv(buildEnvPath); + + if (platform === 'android') { + results.androidResult = result; + } else { + results.iosResult = result; + } + } + } catch (error) { + results.error = error instanceof Error ? error.message : String(error); + console.error(`Environment extraction failed: ${results.error}`); + } + + return results; +} + /** * Builds the complete PR comment body */ function buildCommentBody( buildInfo: BuildInfo, testPlan: TestPlanResult | null, + envValidation: { + androidResult?: EnvValidationResult; + iosResult?: EnvValidationResult; + error?: string; + }, testPlanError?: string, ): string { let body = `${RC_BUILD_COMMENT_MARKER} @@ -87,6 +162,15 @@ ${buildMoreInfoSection(buildInfo)} `; + // Add environment section + if (envValidation.androidResult || envValidation.iosResult) { + body += `---\n\n`; + body += buildEnvValidationSection(envValidation.androidResult, envValidation.iosResult); + } else if (envValidation.error) { + body += `---\n\n`; + body += buildEnvValidationFailureSection(envValidation.error); + } + // Add test plan section if (testPlan) { body += `---\n\n`; @@ -163,8 +247,22 @@ async function main(): Promise { console.log('\nNo AI API keys found, skipping test plan generation'); } + // Extract environment values from build artifacts + console.log('\n=== Build Environment ===\n'); + const envValidation = performEnvValidation(); + + if (envValidation.androidResult || envValidation.iosResult) { + const result = envValidation.androidResult || envValidation.iosResult; + console.log(` - Build Name: ${result?.buildName}`); + console.log(` - Environment: ${result?.extractedValues.METAMASK_ENVIRONMENT}`); + } else if (envValidation.error) { + console.log(` - Error: ${envValidation.error}`); + } else { + console.log(' - No build-env artifacts found'); + } + // Build the comment body - const commentBody = buildCommentBody(buildInfo, testPlan, testPlanError); + const commentBody = buildCommentBody(buildInfo, testPlan, envValidation, testPlanError); // Post comment and minimize old ones console.log(`\n=== Posting Comment to PR #${prNumber} ===\n`); diff --git a/scripts/build-announce/types.ts b/scripts/build-announce/types.ts index 8fd1dc93012..3593075bba0 100644 --- a/scripts/build-announce/types.ts +++ b/scripts/build-announce/types.ts @@ -76,3 +76,11 @@ export interface TestPlanResult { excludedFeatures?: string[]; } +/** + * Environment values extracted from build-env.json + */ +export interface EnvValidationResult { + buildName: string; + extractedValues: Record; +} + diff --git a/scripts/build-announce/validate-env.ts b/scripts/build-announce/validate-env.ts new file mode 100644 index 00000000000..add3ef9f0ed --- /dev/null +++ b/scripts/build-announce/validate-env.ts @@ -0,0 +1,35 @@ +/** + * Environment Extraction Script + * + * Reads build environment values from build-env.json generated during the build. + * + * The build-env.json is generated by: node scripts/apply-build-config.js --write-build-env + */ + +import { existsSync, readFileSync } from 'fs'; +import type { EnvValidationResult } from './types'; + +// Build environment JSON structure (generated by apply-build-config.js) +interface BuildEnvJson { + buildName: string; + buildTime: string; + env: Record; + codeFencing?: string[]; +} + +/** + * Load build environment from JSON file and extract values + */ +export function validateEnv(buildEnvPath: string): EnvValidationResult { + if (!existsSync(buildEnvPath)) { + throw new Error(`Build environment file not found: ${buildEnvPath}`); + } + + const content = readFileSync(buildEnvPath, 'utf-8'); + const buildEnv = JSON.parse(content) as BuildEnvJson; + + return { + buildName: buildEnv.buildName, + extractedValues: buildEnv.env, + }; +} diff --git a/tests/api-mocking/mock-e2e-allowlist.ts b/tests/api-mocking/mock-e2e-allowlist.ts index 46fd155a151..c963fcb71b0 100644 --- a/tests/api-mocking/mock-e2e-allowlist.ts +++ b/tests/api-mocking/mock-e2e-allowlist.ts @@ -22,15 +22,7 @@ export const ALLOWLISTED_HOSTS = [ export const ALLOWLISTED_URLS = [ // Temporarily allow existing live requests during migration - 'https://clients3.google.com/generate_204', 'https://api.avax.network/ext/bc/C/rpc', - // Token SVGs in notifications list - 'https://raw.githubusercontent.com/MetaMask/contract-metadata/master/images/usdc.svg', - 'https://raw.githubusercontent.com/MetaMask/contract-metadata/master/images/shib.svg', - 'https://raw.githubusercontent.com/MetaMask/contract-metadata/master/images/usdt.svg', - 'https://token.api.cx.metamask.io/assets/nativeCurrencyLogos/ethereum.svg', - 'https://raw.githubusercontent.com/MetaMask/contract-metadata/master/images/stETH.svg', - 'https://raw.githubusercontent.com/MetaMask/contract-metadata/master/images/rETH.svg', 'https://signature-insights.api.cx.metamask.io/v1/signature?chainId=0x539', 'https://mainnet.era.zksync.io/', 'https://rpc.atlantischain.network/', diff --git a/tests/api-mocking/mock-responses/defaults/index.ts b/tests/api-mocking/mock-responses/defaults/index.ts index a660afe2cae..70c52657e53 100644 --- a/tests/api-mocking/mock-responses/defaults/index.ts +++ b/tests/api-mocking/mock-responses/defaults/index.ts @@ -31,6 +31,7 @@ import { TRENDING_API_MOCKS } from '../trending-api-mocks.ts'; import { TX_SENTINEL_NETWORKS_MAP } from '../tx-sentinel-networks-map.ts'; import { DIGEST_API_MOCKS } from './digest-api.ts'; import { MONEY_ACCOUNT_MOCKS } from './money-account.ts'; +import { STATIC_ASSETS_MOCKS } from './static-assets.ts'; // Get auth mocks const authMocks = getAuthMocks(); @@ -59,6 +60,7 @@ export const DEFAULT_MOCKS = { ...(TRENDING_API_MOCKS.GET || []), ...(DIGEST_API_MOCKS.GET || []), ...(MONEY_ACCOUNT_MOCKS.GET || []), + ...(STATIC_ASSETS_MOCKS.GET || []), // Chains Network Mock - Provides blockchain network data { urlEndpoint: 'https://chainid.network/chains.json', @@ -205,4 +207,5 @@ export const DEFAULT_MOCKS = { ], DELETE: [], PATCH: [], + HEAD: [...(STATIC_ASSETS_MOCKS.HEAD || [])], }; diff --git a/tests/api-mocking/mock-responses/defaults/static-assets.ts b/tests/api-mocking/mock-responses/defaults/static-assets.ts new file mode 100644 index 00000000000..939cc679b9a --- /dev/null +++ b/tests/api-mocking/mock-responses/defaults/static-assets.ts @@ -0,0 +1,27 @@ +import { MockEventsObject } from '../../../framework'; + +const MINIMAL_SVG = ''; + +export const STATIC_ASSETS_MOCKS: MockEventsObject = { + HEAD: [ + { + urlEndpoint: /^https:\/\/clients3\.google\.com\/generate_204$/, + responseCode: 204, + response: '', + }, + ], + GET: [ + { + urlEndpoint: + /^https:\/\/raw\.githubusercontent\.com\/MetaMask\/contract-metadata\/[^/]+\/images\/.+\.svg$/, + responseCode: 200, + response: MINIMAL_SVG, + }, + { + urlEndpoint: + /^https:\/\/token\.api\.cx\.metamask\.io\/assets\/nativeCurrencyLogos\/.+\.svg$/, + responseCode: 200, + response: MINIMAL_SVG, + }, + ], +}; diff --git a/tests/helpers/swap/swap-mocks.ts b/tests/helpers/swap/swap-mocks.ts index 30caa82c330..2cd14fa55ca 100644 --- a/tests/helpers/swap/swap-mocks.ts +++ b/tests/helpers/swap/swap-mocks.ts @@ -6,6 +6,7 @@ import { setupMockRequest, setupSSEMockRequest, } from '../../api-mocking/helpers/mockHelpers'; +import { getDecodedProxiedURL } from '../../smoke/notifications/utils/helpers'; import { GET_QUOTE_ETH_USDC_RESPONSE, GET_QUOTE_ETH_USDC_RESPONSE_CUSTOM_SLIPPAGE, @@ -30,6 +31,9 @@ const WETH_MAINNET = '0xc02aaa39b223fe8d0a0e5c4f27ead9083c756cc2'; const GOOGLON_MAINNET = '0xba47214edd2bb43099611b208f75e4b42fdcfedc'; const MUSD_MAINNET = '0xaca92e438df0b2401ff60da7e4337b687a2435da'; +/** SocialService leaderboard response shape (empty list is valid for E2E). */ +const SOCIAL_LEADERBOARD_EMPTY_RESPONSE = { traders: [] }; + /** * Mock spot prices so balance display (balance * price) does not show NaN. * Shared by swap and bridge E2E tests. @@ -77,10 +81,64 @@ export async function setupSpotPricesMock(mockServer: Mockttp): Promise { }); } +/** + * Social leaderboard + compliance batch — used by swap `testSpecificMock` and + * bridge/trending specs that do not use swap-mocks’ full mock bundle. + */ +export async function setupSwapSocialAndComplianceMocks( + mockServer: Mockttp, +): Promise { + await setupMockRequest( + mockServer, + { + requestMethod: 'GET', + url: /social\.api\.cx\.metamask\.io\/api\/v1\/leaderboard/, + response: SOCIAL_LEADERBOARD_EMPTY_RESPONSE, + responseCode: 200, + }, + 1001, + ); + + await mockServer + .forPost('/proxy') + .matching((request) => { + try { + const decodedUrl = getDecodedProxiedURL(request.url); + return /compliance\.(dev-api|api|uat-api)\.cx\.metamask\.io\/v1\/wallet\/batch/.test( + decodedUrl, + ); + } catch { + return false; + } + }) + .asPriority(1001) + .thenCallback(async (request) => { + let addresses: string[] = []; + try { + const text = await request.body.getText(); + if (text) { + const parsed = JSON.parse(text) as unknown; + if (Array.isArray(parsed)) { + addresses = parsed.filter( + (a): a is string => typeof a === 'string', + ); + } + } + } catch { + /* ignore malformed body */ + } + return { + statusCode: 200, + json: addresses.map((address) => ({ address, blocked: false })), + }; + }); +} + export const testSpecificMock: TestSpecificMock = async ( mockServer: Mockttp, ) => { await setupSpotPricesMock(mockServer); + await setupSwapSocialAndComplianceMocks(mockServer); // Catch-all for getQuoteStream with no slippage param (initial render before // useInitialSlippage fires). Registered first so specific mocks below at @@ -206,7 +264,10 @@ export const testSpecificMock: TestSpecificMock = async ( await interceptProxyUrl( mockServer, - (url) => url.includes('getQuote') && url.includes('insufficientBal=false'), + (url) => + url.includes('getQuote') && + !url.includes('getQuoteStream') && + url.includes('insufficientBal=false'), (url) => url.replace('insufficientBal=false', 'insufficientBal=true'), ); }; diff --git a/tests/helpers/swap/swap-unified-ui.ts b/tests/helpers/swap/swap-unified-ui.ts index 5cd6c4cbfcd..f78e5f31d52 100644 --- a/tests/helpers/swap/swap-unified-ui.ts +++ b/tests/helpers/swap/swap-unified-ui.ts @@ -1,9 +1,12 @@ import QuoteView from '../../page-objects/swaps/QuoteView'; import SlippageModal from '../../page-objects/swaps/SlippageModal'; import { Assertions } from '../../framework'; +import { createLogger } from '../../framework/logger'; import ActivitiesView from '../../page-objects/Transactions/ActivitiesView'; import { ActivitiesViewSelectorsText } from '../../../app/components/Views/ActivityView/ActivitiesView.testIds'; +const logger = createLogger({ name: 'SwapUnifiedUI' }); + interface SwapOptions { /** Custom slippage percentage (e.g., "2.5" for 2.5%) */ slippage?: string; @@ -30,9 +33,11 @@ export async function submitSwapUnifiedUI( await QuoteView.tapDestinationToken(); await QuoteView.tapToken(chainId, destTokenSymbol); + const getQuoteStarted = Date.now(); await Assertions.expectElementToBeVisible(QuoteView.networkFeeLabel, { timeout: 60000, }); + logger.debug(`⏳ Quote visible after ${Date.now() - getQuoteStarted}ms`); // Dismiss the keypad so quote details (slippage, confirm) are not obscured await QuoteView.dismissKeypad(); diff --git a/tests/smoke/swap/swap-deeplink-smoke.spec.ts b/tests/smoke/swap/swap-deeplink-smoke.spec.ts index 763e422a696..c9ebf45c86a 100644 --- a/tests/smoke/swap/swap-deeplink-smoke.spec.ts +++ b/tests/smoke/swap/swap-deeplink-smoke.spec.ts @@ -11,6 +11,7 @@ import Assertions from '../../framework/Assertions'; import { asDetoxElement } from '../../framework'; import QuoteView from '../../page-objects/swaps/QuoteView'; import { testSpecificMock } from '../../helpers/swap/swap-mocks'; +import TestHelpers from '../../helpers'; import WalletView from '../../page-objects/wallet/WalletView'; // Deep link URLs for testing unified swap/bridge experience @@ -62,7 +63,10 @@ describe( async () => { await loginToApp(); await device.sendToHome(); + // intentional: Detox iOS 16+ sendToHome briefly opens Settings; wait before launchApp({ url }). + if (device.getPlatform() === 'ios') await TestHelpers.delay(1000); await device.launchApp({ + newInstance: false, url: SWAP_DEEPLINK_FULL, }); @@ -121,7 +125,10 @@ describe( async () => { await loginToApp(); await device.sendToHome(); + // intentional: Detox iOS 16+ sendToHome briefly opens Settings; wait before launchApp({ url }). + if (device.getPlatform() === 'ios') await TestHelpers.delay(1000); await device.launchApp({ + newInstance: false, url: SWAP_DEEPLINK_BASE, }); @@ -175,7 +182,10 @@ describe( async () => { await loginToApp(); await device.sendToHome(); + // intentional: Detox iOS 16+ sendToHome briefly opens Settings; wait before launchApp({ url }). + if (device.getPlatform() === 'ios') await TestHelpers.delay(1000); await device.launchApp({ + newInstance: false, url: invalidDeeplink, }); diff --git a/tests/smoke/swap/swap-trending-tokens.spec.ts b/tests/smoke/swap/swap-trending-tokens.spec.ts index 2908f2a8871..b7b625287bd 100644 --- a/tests/smoke/swap/swap-trending-tokens.spec.ts +++ b/tests/smoke/swap/swap-trending-tokens.spec.ts @@ -11,6 +11,7 @@ import TokenOverview from '../../page-objects/wallet/TokenOverview'; import FixtureBuilder from '../../framework/fixtures/FixtureBuilder'; import { prepareSwapsTestEnvironment } from '../../helpers/swap/prepareSwapsTestEnvironment'; import { testSpecificMock } from '../../helpers/swap/bridge-mocks'; +import { setupSwapSocialAndComplianceMocks } from '../../helpers/swap/swap-mocks'; import { GET_QUOTE_ETH_USDC_RESPONSE } from '../../helpers/swap/constants'; import { getDecodedProxiedURL } from '../notifications/utils/helpers'; import { SmokeSwap } from '../../tags'; @@ -77,6 +78,8 @@ const setupSwapsTrendingTokensMock = async (mockServer: Mockttp) => { }, 1001, ); + + await setupSwapSocialAndComplianceMocks(mockServer); }; const setupTrendingTokensMock = async (mockServer: Mockttp) => {