From 7c1c479b30257717611d6f35c0a5456427efcf45 Mon Sep 17 00:00:00 2001 From: Chris Wilcox Date: Wed, 15 Jul 2026 12:17:39 -0700 Subject: [PATCH 01/15] test: add SmokeMMConnect thin slice for multichain Appium smoke CI Migrate the active connection-multichain spec to smoke-appium with e2e fixtures and wire appium-mmconnect-android-smoke on google_apis for Chrome. Co-authored-by: Cursor --- .github/workflows/run-appium-e2e-workflow.yml | 7 +- .../run-appium-smoke-tests-android.yml | 25 +++++ package.json | 1 + tests/smoke-appium/mm-connect/README.md | 33 ++++++ .../mm-connect/connection-multichain.spec.ts | 101 ++++++++++++++++++ .../mm-connect/mm-connect-fixtures.ts | 9 ++ tests/smoke-appium/mm-connect/utils.ts | 101 ++++++++++++++++++ tests/tags.js | 7 ++ 8 files changed, 283 insertions(+), 1 deletion(-) create mode 100644 tests/smoke-appium/mm-connect/README.md create mode 100644 tests/smoke-appium/mm-connect/connection-multichain.spec.ts create mode 100644 tests/smoke-appium/mm-connect/mm-connect-fixtures.ts create mode 100644 tests/smoke-appium/mm-connect/utils.ts diff --git a/.github/workflows/run-appium-e2e-workflow.yml b/.github/workflows/run-appium-e2e-workflow.yml index 2037cbe0e32..23709777d2c 100644 --- a/.github/workflows/run-appium-e2e-workflow.yml +++ b/.github/workflows/run-appium-e2e-workflow.yml @@ -48,6 +48,11 @@ on: required: false type: string default: 'current' + android-tag: + description: 'Android system image tag (default = AOSP; google_apis includes Chrome for browser dApp tests)' + required: false + type: string + default: 'default' permissions: contents: read @@ -87,7 +92,7 @@ jobs: setup-simulator: 'true' android-avd-name: appium_smoke_avd android-api-level: '34' - android-tag: default + android-tag: ${{ inputs.android-tag }} android-abi: x86_64 android-device: pixel_5 configure-keystores: 'false' diff --git a/.github/workflows/run-appium-smoke-tests-android.yml b/.github/workflows/run-appium-smoke-tests-android.yml index 7bed0f144ae..d6af881dee9 100644 --- a/.github/workflows/run-appium-smoke-tests-android.yml +++ b/.github/workflows/run-appium-smoke-tests-android.yml @@ -220,6 +220,31 @@ jobs: runner_provider: ${{ inputs.runner_provider }} secrets: inherit + appium-mmconnect-android-smoke: + if: >- + ${{ + !cancelled() && + (contains(fromJson(inputs.selected_tags), 'ALL') || + contains(fromJson(inputs.selected_tags), 'SmokeMMConnect')) + }} + strategy: + matrix: + split: [1] + fail-fast: false + uses: ./.github/workflows/run-appium-e2e-workflow.yml + with: + test-suite-name: appium-mmconnect-android-smoke-${{ matrix.split }} + platform: android + test_suite_tag: SmokeMMConnect + split_number: ${{ matrix.split }} + total_splits: 1 + test-timeout-minutes: 35 + android-tag: google_apis + build_type: ${{ inputs.build_type }} + metamask_environment: ${{ inputs.metamask_environment }} + runner_provider: ${{ inputs.runner_provider }} + secrets: inherit + appium-money-android-smoke: if: >- ${{ diff --git a/package.json b/package.json index 7e00fff0622..a751fac4cdc 100644 --- a/package.json +++ b/package.json @@ -148,6 +148,7 @@ "run-system-tests:ios-login-sim": "yarn playwright test --project system-ios-login-sim --config tests/playwright.system-emulator.config.ts", "run-system-tests:ios-onboarding-sim": "yarn playwright test --project system-ios-onboarding-sim --config tests/playwright.system-emulator.config.ts", "appium-smoke:android": "yarn playwright test --config tests/playwright.smoke-appium.config.ts --project android-smoke", + "appium-smoke:mmconnect:android": "yarn playwright test --config tests/playwright.smoke-appium.config.ts --project android-smoke --grep SmokeMMConnect", "appium-smoke:ios": "yarn playwright test --config tests/playwright.smoke-appium.config.ts --project ios-smoke", "capture-visual-baselines:android": "CAPTURE_BASELINES=true AI_VISUAL_TESTING_ENABLED=true yarn playwright test --project system-android-login-emu --config tests/playwright.system-emulator.config.ts", "capture-visual-baselines:ios": "CAPTURE_BASELINES=true AI_VISUAL_TESTING_ENABLED=true yarn playwright test --project system-ios-login-sim --config tests/playwright.system-emulator.config.ts", diff --git a/tests/smoke-appium/mm-connect/README.md b/tests/smoke-appium/mm-connect/README.md new file mode 100644 index 00000000000..b51b1a884d6 --- /dev/null +++ b/tests/smoke-appium/mm-connect/README.md @@ -0,0 +1,33 @@ +# MetaMask Connect Appium Smoke (`SmokeMMConnect`) + +Thin-slice Appium smoke coverage for MetaMask Connect. Specs run on PR CI via +`appium-mmconnect-android-smoke` and locally with: + +```bash +yarn appium-smoke:mmconnect:android +``` + +Requires a **main-e2e release** APK (`HAS_TEST_OVERRIDES=true`). See +[`docs/testing/appium-smoke-testing.md`](../../../docs/testing/appium-smoke-testing.md). + +## Active specs + +| Spec | Status | +|------|--------| +| `connection-multichain.spec.ts` | Active — Multichain API connect via Browser Playground | + +Remaining MMConnect specs still live under `tests/performance/mm-connect/` +(mostly `test.skip` / [WAPI-1511](https://consensyssoftware.atlassian.net/browse/WAPI-1511)) +until a follow-up migration. + +## Wallet & mocks + +- Fixture: `FixtureBuilder().withSolanaAccountPermission().build()` (standard e2e vault) +- Login: `loginToAppPlaywright({ scenarioType: 'e2e' })` +- API mocks: `DEFAULT_MOCKS` via `withFixtures` + +## CI notes + +Android Appium smoke for this tag uses the `google_apis` system image so Chrome +(`com.android.chrome`) is available. Specs call +`launchMobileBrowser({ safelyOnboardChrome: true })` to dismiss Chrome FRE. diff --git a/tests/smoke-appium/mm-connect/connection-multichain.spec.ts b/tests/smoke-appium/mm-connect/connection-multichain.spec.ts new file mode 100644 index 00000000000..14a40b9b2c8 --- /dev/null +++ b/tests/smoke-appium/mm-connect/connection-multichain.spec.ts @@ -0,0 +1,101 @@ +import { test as appiumTest } from '../../framework/fixtures/playwright/index.js'; +import { SmokeMMConnect } from '../../tags.js'; +import { loginToAppPlaywright } from '../../flows/wallet.flow.js'; +import { withFixtures } from '../../framework/fixtures/FixtureHelper.js'; +import BrowserPlaygroundDapp from '../../page-objects/MMConnect/BrowserPlaygroundDapp.js'; +import AndroidScreenHelpers from '../../page-objects/MMConnect/AndroidScreenHelpers.js'; +import DappConnectionModal from '../../page-objects/MMConnect/DappConnectionModal.js'; +import PlaywrightContextHelpers from '../../framework/PlaywrightContextHelpers.js'; +import { + DappServer, + DappVariants, + PlaywrightGestures, + TestDapps, + asPlaywrightElement, + sleep, +} from '../../framework/index.js'; +import { + getDappUrlForBrowser, + setupAdbReverse, + cleanupAdbReverse, + waitForDappServerReady, + unlockIfLockScreenVisible, +} from './utils.js'; +import { + launchMobileBrowser, + navigateToDapp, + switchToMobileBrowser, +} from '../../flows/native-browser.flow.js'; +import { multichainBrowserFixture } from './mm-connect-fixtures.js'; + +const DAPP_PORT = 8090; + +const playgroundServer = new DappServer({ + dappCounter: 0, + rootDirectory: TestDapps[DappVariants.BROWSER_PLAYGROUND].dappPath, + dappVariant: DappVariants.BROWSER_PLAYGROUND, +}); + +appiumTest.describe(SmokeMMConnect('Multichain browser connect'), () => { + appiumTest.beforeAll(async () => { + playgroundServer.setServerPort(DAPP_PORT); + await playgroundServer.start(); + await waitForDappServerReady(DAPP_PORT); + setupAdbReverse(DAPP_PORT); + }); + + appiumTest.afterAll(async () => { + cleanupAdbReverse(DAPP_PORT); + await playgroundServer.stop(); + }); + + appiumTest( + '@metamask/connect-multichain - Connect via Multichain API to Local Browser Playground', + async ({ driver: _driver, currentDeviceDetails }) => { + await withFixtures( + { + fixture: multichainBrowserFixture(), + restartDevice: true, + currentDeviceDetails, + }, + async () => { + const DAPP_URL = getDappUrlForBrowser(currentDeviceDetails.platform); + + await PlaywrightContextHelpers.withNativeAction(async () => { + await loginToAppPlaywright({ scenarioType: 'e2e' }); + await launchMobileBrowser({ safelyOnboardChrome: true }); + await navigateToDapp(DAPP_URL); + }); + + await PlaywrightContextHelpers.withWebAction(async () => { + await BrowserPlaygroundDapp.waitForConnectButtonVisible(15000); + await BrowserPlaygroundDapp.tapConnect(); + }, DAPP_URL); + + await PlaywrightContextHelpers.withNativeAction(async () => { + await AndroidScreenHelpers.tapOpenDeeplinkWithMetaMask(); + await unlockIfLockScreenVisible(); + await DappConnectionModal.tapConnectButton(); + }); + + await switchToMobileBrowser(); + await sleep(500); + + await PlaywrightContextHelpers.withWebAction(async () => { + await BrowserPlaygroundDapp.assertMultichainConnected(true); + await PlaywrightGestures.scrollIntoView( + await asPlaywrightElement( + BrowserPlaygroundDapp.getScopeCard('eip155:1'), + ), + ); + await BrowserPlaygroundDapp.assertScopeCardVisible('eip155:1'); + }, DAPP_URL); + + await PlaywrightContextHelpers.withWebAction(async () => { + await BrowserPlaygroundDapp.tapDisconnect(); + }, DAPP_URL); + }, + ); + }, + ); +}); diff --git a/tests/smoke-appium/mm-connect/mm-connect-fixtures.ts b/tests/smoke-appium/mm-connect/mm-connect-fixtures.ts new file mode 100644 index 00000000000..c03804720a0 --- /dev/null +++ b/tests/smoke-appium/mm-connect/mm-connect-fixtures.ts @@ -0,0 +1,9 @@ +import FixtureBuilder from '../../framework/fixtures/FixtureBuilder.js'; + +/** + * Fixture for browser Multichain Connect smoke tests. + * Uses the standard e2e CI wallet plus Solana CAIP-25 permission scopes. + */ +export function multichainBrowserFixture() { + return new FixtureBuilder().withSolanaAccountPermission().build(); +} diff --git a/tests/smoke-appium/mm-connect/utils.ts b/tests/smoke-appium/mm-connect/utils.ts new file mode 100644 index 00000000000..19cd1362f7d --- /dev/null +++ b/tests/smoke-appium/mm-connect/utils.ts @@ -0,0 +1,101 @@ +/* eslint-disable import-x/no-nodejs-modules */ +import { execSync } from 'child_process'; +import LoginView from '../../page-objects/wallet/LoginView'; +import { + PlaywrightAssertions, + sleep, + createLogger, +} from '../../framework'; +import { asPlaywrightElement } from '../../framework/EncapsulatedElement'; +import { loginToAppPlaywright } from '../../flows/wallet.flow'; + +const logger = createLogger({ + name: 'MMConnectUtils', +}); + +const DEFAULT_DAPP_PORT = 8090; +const UNLOCK_WAIT_MS = 5000; +const DAPP_READY_POLL_MS = 500; + +/** + * If the app auto-locked and the unlock/login screen is displayed, unlock with + * the standard e2e fixture password. + */ +export async function unlockIfLockScreenVisible(): Promise { + try { + await PlaywrightAssertions.expectElementToBeVisible( + asPlaywrightElement(LoginView.container), + { timeout: UNLOCK_WAIT_MS }, + ); + await loginToAppPlaywright({ scenarioType: 'e2e' }); + } catch { + // Unlock screen not shown within timeout; continue + } +} + +/** + * Wait for the dapp server to be listening on the given port. + */ +export async function waitForDappServerReady( + port: number, + timeoutMs = 15000, +): Promise { + const url = `http://localhost:${port}`; + const deadline = Date.now() + timeoutMs; + while (Date.now() < deadline) { + try { + const controller = new AbortController(); + const timeoutId = setTimeout(() => controller.abort(), 2000); + const res = await fetch(url, { signal: controller.signal }); + clearTimeout(timeoutId); + if (res.ok || res.status < 500) { + return; + } + } catch { + // Server not ready or connection refused; keep polling + } + await sleep(DAPP_READY_POLL_MS); + } + throw new Error( + `Dapp server on port ${port} did not become ready within ${timeoutMs}ms`, + ); +} + +/** + * Get the dapp URL for mobile browser access. + * Android emulator browser needs 10.0.2.2 to reach the host machine. + */ +export function getDappUrlForBrowser( + platform: string, + port = DEFAULT_DAPP_PORT, +): string { + const host = platform === 'android' ? '10.0.2.2' : 'localhost'; + return `http://${host}:${port}`; +} + +/** + * Set up ADB reverse port forwarding for Android emulator. + */ +export function setupAdbReverse(port: number): void { + try { + execSync(`adb reverse tcp:${port} tcp:${port}`, { stdio: 'pipe' }); + logger.info(`ADB reverse port ${port} configured`); + } catch (error) { + const message = error instanceof Error ? error.message : String(error); + logger.warn( + `Could not set up ADB reverse (may be expected on iOS): ${message}`, + ); + } +} + +/** + * Clean up ADB reverse port forwarding. + */ +export function cleanupAdbReverse(port: number): void { + try { + execSync(`adb reverse --remove tcp:${port}`, { stdio: 'pipe' }); + logger.info(`ADB reverse port ${port} removed`); + } catch { + // Ignore cleanup errors + } +} diff --git a/tests/tags.js b/tests/tags.js index 4301c305182..68318fc1827 100644 --- a/tests/tags.js +++ b/tests/tags.js @@ -77,6 +77,11 @@ const smokeTags = { description: 'Tests the MetaMask Snaps extensibility platform. Covers snap lifecycle: installation from npm, enabling/disabling installed snaps, and removal with keyring warnings for snaps managing accounts. Tests snap Ethereum provider access: eth_chainId, eth_accounts, personal_sign, eth_signTypedData_v4, and wallet_switchEthereumChain. Validates snap dialog systems for alerts and confirmations with approve/cancel flows. Tests snap capabilities: persistent state management (snap_manageState for set/get/clear), network access for external API calls, WebAssembly (WASM) execution, interactive UI rendering with JSX components, cronjob scheduling for background tasks, entropy generation for randomness, file handling, and BIP-32/BIP-44 key derivation for account management. Also covers preinstalled snaps, snap UI links, lifecycle events, user preference access, image handling in snap UIs, and background event listeners. Snaps enable non-EVM chain support like Solana account derivation.', }, + smokeMMConnect: { + tag: 'SmokeMMConnect:', + description: + 'Tests MetaMask Connect flows via the system browser (Chrome on Android, Safari on iOS) and local Browser Playground dApp. Covers Multichain API connect/disconnect, session scopes (eip155:1), and deeplink handoff back to MetaMask for connection approval. Specs live in tests/smoke-appium/mm-connect/ and run via Appium smoke CI. Uses withFixtures + the standard e2e fixture wallet (not baked-SRP performance builds). When changes touch MMConnect connection modals, multichain sessions, native browser deeplinks, or Browser Playground integration, select this tag. Related to SmokeMultiChainAPI and SmokeNetworkExpansion for CAIP-25 / multi-chain provider behavior.', + }, }; const flaskTags = {}; @@ -144,6 +149,7 @@ const { SmokeSeedlessOnboarding, SmokeBrowser, SmokeSnaps, + SmokeMMConnect, } = createSmokeDescribeFunctions(smokeTags); const { @@ -177,6 +183,7 @@ export { SmokePredictions, SmokeSeedlessOnboarding, SmokeBrowser, + SmokeMMConnect, RegressionAccounts, RegressionConfirmations, RegressionIdentity, From cc259267a812e810c4bf69c038b5caa9686e1ca7 Mon Sep 17 00:00:00 2001 From: Chris Wilcox Date: Wed, 15 Jul 2026 13:02:16 -0700 Subject: [PATCH 02/15] fix(test): stabilize Chrome navigation for MMConnect Appium smoke MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Format mm-connect utils for CI format:check. Harden Android Chrome launch (clear → FRE flags → force-stop) and open dapp URLs via VIEW intent to avoid flaky omnibox selectors on google_apis emulators. Co-authored-by: Cursor --- tests/flows/native-browser.flow.ts | 109 ++++++++++++++++++++++--- tests/framework/PlaywrightUtilities.ts | 59 +++++++++++++ tests/smoke-appium/mm-connect/utils.ts | 6 +- 3 files changed, 159 insertions(+), 15 deletions(-) diff --git a/tests/flows/native-browser.flow.ts b/tests/flows/native-browser.flow.ts index 9af180636b6..55e2dbf5393 100644 --- a/tests/flows/native-browser.flow.ts +++ b/tests/flows/native-browser.flow.ts @@ -17,11 +17,20 @@ const CHROME_DISMISS_TIMEOUT_MS = 5000; const CHROME_UI_SETTLE_MS = 800; /** - * Dismisses the "Enhanced ad privacy" dialog if present. + * Dismisses common Chrome first-run / privacy / default-browser dialogs if present. * @returns void */ const dismissChromeAdPrivacyIfPresent = async () => { - const dismissTexts = ['Got it', 'No thanks', 'Skip', 'Continue']; + const dismissTexts = [ + 'Got it', + 'No thanks', + 'Skip', + 'Continue', + 'Accept & continue', + 'Accept and continue', + 'Use without an account', + 'More', + ]; for (const text of dismissTexts) { try { const element = await PlaywrightMatchers.getElementByText(text); @@ -35,6 +44,7 @@ const dismissChromeAdPrivacyIfPresent = async () => { /** * Dismisses the "Chrome notifications make things easier" modal if present. + * Prefer text — resource IDs differ across Chrome versions on emulators. * @returns void */ const dismissChromeNotificationsIfPresent = async () => { @@ -86,6 +96,43 @@ const safelyOnboardChromeBrowser = async () => { } }; +/** + * Wait until Chrome NTP/omnibox is interactable, dismissing leftover dialogs. + * google_apis emulator Chrome often uses placeholder text instead of stable IDs. + */ +const waitForChromeNavigationReady = async () => { + const deadline = Date.now() + 20_000; + while (Date.now() < deadline) { + PlaywrightUtilities.collapseStatusBar(); + try { + await withTimeout( + dismissChromeNotificationsIfPresent(), + 2_000, + 'dismissChromeNotificationsReady', + ); + } catch { + // Modal not present + } + + for (const probe of [ + () => asPlaywrightElement(ChromeBrowserView.chromeHomePageSearchBox), + () => asPlaywrightElement(ChromeBrowserView.chromeUrlBar), + () => + PlaywrightMatchers.getElementByText('Search or type web address', true), + ]) { + try { + const element = await probe(); + if (await element.isDisplayed()) { + return; + } + } catch { + // Try next probe + } + } + await new Promise((r) => setTimeout(r, 500)); + } +}; + /** * Launches the mobile browser * @returns A promise that resolves when the launch is complete @@ -98,13 +145,17 @@ export const launchMobileBrowser = async ({ return; } - PlaywrightUtilities.setupChromeDisableFre(); + // Clear before disable-fre so the next cold start picks up chrome-command-line. PlaywrightUtilities.clearChromeData(); + PlaywrightUtilities.setupChromeDisableFre(); + PlaywrightUtilities.grantChromeNotificationPermission(); + PlaywrightUtilities.forceStopChrome(); await PlaywrightGestures.activateApp(undefined, CHROME_PACKAGE); if (safelyOnboardChrome) { await safelyOnboardChromeBrowser(); } + await waitForChromeNavigationReady(); await new Promise((r) => setTimeout(r, CHROME_UI_SETTLE_MS)); }; @@ -126,17 +177,55 @@ export const switchToMobileBrowser = async () => { * @returns A promise that resolves when the navigation is complete */ export const navigateToDappAndroid = async (url: string) => { + PlaywrightUtilities.collapseStatusBar(); + + // Prefer VIEW intent — omnibox IDs/text are unreliable on fresh google_apis Chrome. + try { + PlaywrightUtilities.openUrlInChrome(url); + await new Promise((r) => setTimeout(r, CHROME_UI_SETTLE_MS)); + return; + } catch { + // Fall back to omnibox UI navigation + } + try { await ChromeBrowserView.tapSearchBox(); } catch { - // NTP search box not present — tap URL bar directly + try { + // Newer Chrome on google_apis images may not expose search_box_text. + await PlaywrightGestures.waitAndTap( + await PlaywrightMatchers.getElementByText( + 'Search or type web address', + true, + ), + ); + } catch { + // NTP search box not present — tap URL bar directly + } + } + try { + await ChromeBrowserView.tapUrlBar(); + } catch { + // Omnibox may already be focused after tapping the search placeholder. + } + + try { + await PlaywrightGestures.typeText( + await asPlaywrightElement(ChromeBrowserView.chromeUrlBar), + url, + ); + } catch { + const editText = await PlaywrightMatchers.getElementByXPath( + '//android.widget.EditText', + ); + await PlaywrightGestures.typeText(editText, url); + } + try { + await ChromeBrowserView.tapSelectDappUrl(); + } catch { + // Suggestion row resource IDs vary; Enter submits the omnibox URL. + await PlaywrightGestures.submitAndroidUrlBar(); } - await ChromeBrowserView.tapUrlBar(); - await PlaywrightGestures.typeText( - await asPlaywrightElement(ChromeBrowserView.chromeUrlBar), - url, - ); - await ChromeBrowserView.tapSelectDappUrl(); }; /** diff --git a/tests/framework/PlaywrightUtilities.ts b/tests/framework/PlaywrightUtilities.ts index 9dadd1c391e..38c8132c03f 100644 --- a/tests/framework/PlaywrightUtilities.ts +++ b/tests/framework/PlaywrightUtilities.ts @@ -455,6 +455,65 @@ class PlaywrightUtilities { } } + /** + * Force-stop Chrome so the next launch reads `/data/local/tmp/chrome-command-line`. + */ + static forceStopChrome(): void { + try { + execSync(`adb shell am force-stop ${CHROME_PACKAGE}`, { stdio: 'pipe' }); + } catch (error) { + const message = error instanceof Error ? error.message : String(error); + logger.warn(`Could not force-stop Chrome: ${message}`); + } + } + + /** + * Open a URL in Chrome via VIEW intent (bypasses omnibox UI flakiness on CI). + */ + static openUrlInChrome(url: string): void { + const escapedUrl = url.replace(/"/g, '\\"'); + try { + execSync( + `adb shell am start -a android.intent.action.VIEW -d "${escapedUrl}" ${CHROME_PACKAGE}`, + { stdio: 'pipe' }, + ); + } catch (error) { + const message = error instanceof Error ? error.message : String(error); + throw new Error(`Failed to open URL in Chrome via intent: ${message}`); + } + } + + /** + * Grant notification permission so Chrome does not block the NTP with the + * "Chrome notifications make things easier" dialog on google_apis emulators. + */ + static grantChromeNotificationPermission(): void { + try { + execSync( + `adb shell pm grant ${CHROME_PACKAGE} android.permission.POST_NOTIFICATIONS`, + { stdio: 'pipe' }, + ); + } catch (error) { + const message = error instanceof Error ? error.message : String(error); + logger.warn( + `Could not grant Chrome POST_NOTIFICATIONS (dialog may show): ${message}`, + ); + } + } + + /** + * Collapse the status bar so heads-up notifications (e.g. Play services) + * do not cover Chrome's omnibox on CI emulators. + */ + static collapseStatusBar(): void { + try { + execSync('adb shell cmd statusbar collapse', { stdio: 'pipe' }); + } catch (error) { + const message = error instanceof Error ? error.message : String(error); + logger.warn(`Could not collapse status bar: ${message}`); + } + } + /** * Resolves {@link LaunchArgs} defaults for Playwright + Appium (same logical defaults as * `FixtureHelper` Detox Android: fallback ports + URL blacklist + account-activity WS fallback). diff --git a/tests/smoke-appium/mm-connect/utils.ts b/tests/smoke-appium/mm-connect/utils.ts index 19cd1362f7d..a834285f782 100644 --- a/tests/smoke-appium/mm-connect/utils.ts +++ b/tests/smoke-appium/mm-connect/utils.ts @@ -1,11 +1,7 @@ /* eslint-disable import-x/no-nodejs-modules */ import { execSync } from 'child_process'; import LoginView from '../../page-objects/wallet/LoginView'; -import { - PlaywrightAssertions, - sleep, - createLogger, -} from '../../framework'; +import { PlaywrightAssertions, sleep, createLogger } from '../../framework'; import { asPlaywrightElement } from '../../framework/EncapsulatedElement'; import { loginToAppPlaywright } from '../../flows/wallet.flow'; From d4d9e4cb9935cbe6b9d002e09764f02666d94589 Mon Sep 17 00:00:00 2001 From: Chris Wilcox Date: Wed, 15 Jul 2026 13:03:20 -0700 Subject: [PATCH 03/15] fix(test): avoid eslint no-shadow in Chrome browser flow Co-authored-by: Cursor --- tests/flows/native-browser.flow.ts | 12 ++++++------ 1 file changed, 6 insertions(+), 6 deletions(-) diff --git a/tests/flows/native-browser.flow.ts b/tests/flows/native-browser.flow.ts index 55e2dbf5393..82a195e83bf 100644 --- a/tests/flows/native-browser.flow.ts +++ b/tests/flows/native-browser.flow.ts @@ -33,8 +33,8 @@ const dismissChromeAdPrivacyIfPresent = async () => { ]; for (const text of dismissTexts) { try { - const element = await PlaywrightMatchers.getElementByText(text); - await PlaywrightGestures.waitAndTap(element); + const dismissControl = await PlaywrightMatchers.getElementByText(text); + await PlaywrightGestures.waitAndTap(dismissControl); return; } catch { // This text not found, try next @@ -48,8 +48,8 @@ const dismissChromeAdPrivacyIfPresent = async () => { * @returns void */ const dismissChromeNotificationsIfPresent = async () => { - const element = await PlaywrightMatchers.getElementByText('No thanks'); - await PlaywrightGestures.waitAndTap(element); + const noThanks = await PlaywrightMatchers.getElementByText('No thanks'); + await PlaywrightGestures.waitAndTap(noThanks); }; /** @@ -121,8 +121,8 @@ const waitForChromeNavigationReady = async () => { PlaywrightMatchers.getElementByText('Search or type web address', true), ]) { try { - const element = await probe(); - if (await element.isDisplayed()) { + const chromeTarget = await probe(); + if (await chromeTarget.isDisplayed()) { return; } } catch { From 30bde6f8fadf4139448c57487c16b06b14e22100 Mon Sep 17 00:00:00 2001 From: Chris Wilcox Date: Wed, 15 Jul 2026 13:17:32 -0700 Subject: [PATCH 04/15] fix(test): use isVisible for Chrome readiness probe MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit PlaywrightElement exposes isVisible, not isDisplayed — fixes lint:tsc. Co-authored-by: Cursor --- tests/flows/native-browser.flow.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/tests/flows/native-browser.flow.ts b/tests/flows/native-browser.flow.ts index 82a195e83bf..aaf5dac73ec 100644 --- a/tests/flows/native-browser.flow.ts +++ b/tests/flows/native-browser.flow.ts @@ -122,7 +122,7 @@ const waitForChromeNavigationReady = async () => { ]) { try { const chromeTarget = await probe(); - if (await chromeTarget.isDisplayed()) { + if (await chromeTarget.isVisible()) { return; } } catch { From f7e55010f4a7e824bc34965bbcca3200eb3e7e17 Mon Sep 17 00:00:00 2001 From: Chris Wilcox Date: Wed, 15 Jul 2026 13:58:39 -0700 Subject: [PATCH 05/15] fix(test): prefer Chrome webview for MMConnect dapp URL matching CI loaded the playground in Chrome but Appium selected MetaMask's webview when URL metadata was stale. Prefer WEBVIEW_chrome for http dapps, accept emulator host aliases, and fix VIEW intent -p packaging. Co-authored-by: Cursor --- tests/flows/native-browser.flow.ts | 5 +- tests/framework/PlaywrightContextHelpers.ts | 133 ++++++++++++++++++-- tests/framework/PlaywrightUtilities.ts | 23 ++-- 3 files changed, 142 insertions(+), 19 deletions(-) diff --git a/tests/flows/native-browser.flow.ts b/tests/flows/native-browser.flow.ts index aaf5dac73ec..7489f8e8fb4 100644 --- a/tests/flows/native-browser.flow.ts +++ b/tests/flows/native-browser.flow.ts @@ -16,6 +16,9 @@ const CHROME_DISMISS_TIMEOUT_MS = 5000; /** Delay after dismissals so Chrome UI can settle before we interact with the URL bar. Kept short to avoid app auto-lock. */ const CHROME_UI_SETTLE_MS = 800; +/** Extra settle after VIEW-intent navigation so Chrome can finish loading the dapp. */ +const CHROME_VIEW_INTENT_SETTLE_MS = 3000; + /** * Dismisses common Chrome first-run / privacy / default-browser dialogs if present. * @returns void @@ -182,7 +185,7 @@ export const navigateToDappAndroid = async (url: string) => { // Prefer VIEW intent — omnibox IDs/text are unreliable on fresh google_apis Chrome. try { PlaywrightUtilities.openUrlInChrome(url); - await new Promise((r) => setTimeout(r, CHROME_UI_SETTLE_MS)); + await new Promise((r) => setTimeout(r, CHROME_VIEW_INTENT_SETTLE_MS)); return; } catch { // Fall back to omnibox UI navigation diff --git a/tests/framework/PlaywrightContextHelpers.ts b/tests/framework/PlaywrightContextHelpers.ts index 6675fd949b0..af4578bceb8 100644 --- a/tests/framework/PlaywrightContextHelpers.ts +++ b/tests/framework/PlaywrightContextHelpers.ts @@ -38,7 +38,8 @@ export default class PlaywrightContextHelpers { try { await withTimeout( getDriver().switchContext({ - url: new RegExp(dappUrl.replace(/[.*+?^${}()|[\]\\]/g, '\\$&')), + // Match host aliases used by emulator networking + adb reverse. + url: this.buildDappUrlPattern(dappUrl), androidWebviewConnectTimeout: this.WEBVIEW_TIMEOUT_MS, }), this.WEBVIEW_SWITCH_TIMEOUT_MS, @@ -58,6 +59,28 @@ export default class PlaywrightContextHelpers { await this.switchToWebViewWithRetry(dappUrl); } + /** + * Build a URL matcher that accepts 10.0.2.2 / localhost / 127.0.0.1 aliases. + */ + private static buildDappUrlPattern(dappUrl: string): RegExp { + try { + const parsed = new URL(dappUrl); + const port = parsed.port ? `:${parsed.port}` : ''; + const path = + parsed.pathname === '/' + ? '/?' + : parsed.pathname.replace(/[.*+?^${}()|[\]\\]/g, '\\$&'); + if (['10.0.2.2', 'localhost', '127.0.0.1'].includes(parsed.hostname)) { + return new RegExp( + `https?://(?:10\\.0\\.2\\.2|localhost|127\\.0\\.0\\.1)${port}${path}`, + ); + } + } catch { + // Fall through to exact escape + } + return new RegExp(dappUrl.replace(/[.*+?^${}()|[\]\\]/g, '\\$&')); + } + private static async switchToWebViewWithRetry( dappUrl: string, ): Promise { @@ -107,23 +130,47 @@ export default class PlaywrightContextHelpers { const targetsLocalhost = Boolean(dappUrl?.includes('localhost')); if (dappUrl) { - const urlMatch = webviews.find((ctx) => { - if (!ctx.url?.includes(dappUrl)) { - return false; - } - return targetsLocalhost || !/localhost/i.test(ctx.url ?? ''); - }); + const urlMatch = webviews.find((ctx) => + this.contextMatchesDappUrl(ctx, dappUrl), + ); if (urlMatch) return urlMatch; } const filtered = webviews.filter((ctx) => { + const isLocalhostAlias = + Boolean(dappUrl) && + Boolean(ctx.url) && + this.urlsReferToSameDapp(ctx.url as string, dappUrl as string); const shouldAvoid = /devtools/i.test(ctx.id) || - (ctx.url && /chrome|devtools/i.test(ctx.url)) || - (!targetsLocalhost && ctx.url && /localhost/i.test(ctx.url)); + (ctx.url && /chrome:\/\/|devtools/i.test(ctx.url)) || + (!targetsLocalhost && + !isLocalhostAlias && + ctx.url && + /localhost/i.test(ctx.url)); return !shouldAvoid; }); + // When Chrome is foregrounded (MMConnect native browser), prefer + // WEBVIEW_chrome over the MetaMask in-app webview if URL metadata is stale. + if (await PlatformDetector.isAndroid()) { + try { + const currentPackage = (await getDriver().execute( + 'mobile: getCurrentPackage', + )) as string; + if (/chrome/i.test(currentPackage ?? '')) { + const chromeWebview = filtered.find((ctx) => + this.isChromeWebview(ctx), + ); + if (chromeWebview) { + return chromeWebview; + } + } + } catch { + // Ignore package probe failures and fall through. + } + } + const packageId = (await PlatformDetector.isAndroid()) ? APP_PACKAGE_IDS.ANDROID : APP_PACKAGE_IDS.IOS; @@ -134,6 +181,70 @@ export default class PlaywrightContextHelpers { ); } + private static isChromeWebview(ctx: DetailedContext): boolean { + const androidCtx = ctx as AndroidDetailedContext; + return ( + /chrome/i.test(ctx.id) || + androidCtx.packageName === 'com.android.chrome' || + /chrome/i.test(androidCtx.packageName ?? '') + ); + } + + private static contextMatchesDappUrl( + ctx: DetailedContext, + dappUrl: string, + ): boolean { + if (ctx.url && this.urlsReferToSameDapp(ctx.url, dappUrl)) { + return true; + } + const title = ctx.title ?? ''; + // Playground title is stable when Chrome URL metadata is empty on CI. + if (/multichain api test dapp/i.test(title) && /:8090\b/.test(dappUrl)) { + return true; + } + return false; + } + + private static urlsReferToSameDapp( + candidateUrl: string, + dappUrl: string, + ): boolean { + if (candidateUrl.includes(dappUrl)) { + return true; + } + try { + const target = new URL(dappUrl); + const candidate = new URL(candidateUrl); + const hostAliases = new Set([ + '10.0.2.2', + 'localhost', + '127.0.0.1', + target.hostname, + candidate.hostname, + ]); + const sameHostFamily = + hostAliases.has(target.hostname) && hostAliases.has(candidate.hostname); + const samePort = + (candidate.port || defaultPort(candidate.protocol)) === + (target.port || defaultPort(target.protocol)); + const samePath = + candidate.pathname.replace(/\/$/, '') === + target.pathname.replace(/\/$/, ''); + return sameHostFamily && samePort && samePath; + } catch { + const port = dappUrl.match(/:(\d+)/)?.[1]; + return Boolean( + port && + candidateUrl.includes(`:${port}`) && + !/chrome:\/\//i.test(candidateUrl), + ); + } + + function defaultPort(protocol: string): string { + return protocol === 'https:' ? '443' : '80'; + } + } + private static async warmWebViewContext(): Promise { if (!(await PlatformDetector.isAndroid())) { return; @@ -161,7 +272,9 @@ export default class PlaywrightContextHelpers { } const webviews = await this.getDetailedWebviews(); - const match = webviews.find((ctx) => ctx.url?.includes(dappUrl)); + const match = webviews.find((ctx) => + this.contextMatchesDappUrl(ctx, dappUrl), + ); const pageId = (match as AndroidContextWithPage | undefined)?.webviewPageId; if (!pageId) { diff --git a/tests/framework/PlaywrightUtilities.ts b/tests/framework/PlaywrightUtilities.ts index 38c8132c03f..181f9cc8812 100644 --- a/tests/framework/PlaywrightUtilities.ts +++ b/tests/framework/PlaywrightUtilities.ts @@ -469,18 +469,25 @@ class PlaywrightUtilities { /** * Open a URL in Chrome via VIEW intent (bypasses omnibox UI flakiness on CI). + * Uses `-p` (package) — a bare trailing package name is treated as a component + * and fails on google_apis emulator Chrome. */ static openUrlInChrome(url: string): void { const escapedUrl = url.replace(/"/g, '\\"'); - try { - execSync( - `adb shell am start -a android.intent.action.VIEW -d "${escapedUrl}" ${CHROME_PACKAGE}`, - { stdio: 'pipe' }, - ); - } catch (error) { - const message = error instanceof Error ? error.message : String(error); - throw new Error(`Failed to open URL in Chrome via intent: ${message}`); + const attempts = [ + `adb shell am start -a android.intent.action.VIEW -d "${escapedUrl}" -p ${CHROME_PACKAGE}`, + `adb shell am start -a android.intent.action.VIEW -d "${escapedUrl}" -n ${CHROME_PACKAGE}/com.google.android.apps.chrome.Main`, + ]; + let lastMessage = ''; + for (const command of attempts) { + try { + execSync(command, { stdio: 'pipe' }); + return; + } catch (error) { + lastMessage = error instanceof Error ? error.message : String(error); + } } + throw new Error(`Failed to open URL in Chrome via intent: ${lastMessage}`); } /** From d271a93df9fa7e9f2112bdb993ec35572beb8330 Mon Sep 17 00:00:00 2001 From: Chris Wilcox Date: Wed, 15 Jul 2026 13:59:25 -0700 Subject: [PATCH 06/15] fix(test): fall back to omnibox if Chrome VIEW intent stays on NTP After opening the dapp via VIEW intent, dismiss leftover Chrome dialogs and only skip omnibox navigation when the NTP search placeholder is gone. Co-authored-by: Cursor --- tests/flows/native-browser.flow.ts | 23 ++++++++++++++++++++++- 1 file changed, 22 insertions(+), 1 deletion(-) diff --git a/tests/flows/native-browser.flow.ts b/tests/flows/native-browser.flow.ts index 7489f8e8fb4..2ca31957fe5 100644 --- a/tests/flows/native-browser.flow.ts +++ b/tests/flows/native-browser.flow.ts @@ -186,7 +186,28 @@ export const navigateToDappAndroid = async (url: string) => { try { PlaywrightUtilities.openUrlInChrome(url); await new Promise((r) => setTimeout(r, CHROME_VIEW_INTENT_SETTLE_MS)); - return; + try { + await withTimeout( + dismissChromeAdPrivacyIfPresent(), + CHROME_DISMISS_TIMEOUT_MS, + 'dismissChromeAfterViewIntent', + ); + } catch { + // No post-navigation dialog + } + // If Chrome is still on the NTP, the intent was ignored — use omnibox. + try { + const ntpSearch = await PlaywrightMatchers.getElementByText( + 'Search or type web address', + true, + ); + if (!(await ntpSearch.isVisible())) { + return; + } + } catch { + // NTP placeholder absent — assume the dapp URL loaded. + return; + } } catch { // Fall back to omnibox UI navigation } From bd92029ed3b8500d1166593359336b5ad609bbef Mon Sep 17 00:00:00 2001 From: Chris Wilcox Date: Wed, 15 Jul 2026 14:45:46 -0700 Subject: [PATCH 07/15] fix(test): drive MMConnect dapp via Chrome CDP on emulator Appium switchContext(WEBVIEW_chrome) hangs during Chromedriver session creation on google_apis Chrome 113 despite a live debugger URL. Bypass Chromedriver for smoke by attaching over CDP and use localhost+adb reverse. Co-authored-by: Cursor --- tests/framework/ChromeCdpHelpers.ts | 401 ++++++++++++++++++ .../emulator/EmulatorConfigBuilder.ts | 3 + .../mm-connect/connection-multichain.spec.ts | 43 +- tests/smoke-appium/mm-connect/utils.ts | 9 +- 4 files changed, 432 insertions(+), 24 deletions(-) create mode 100644 tests/framework/ChromeCdpHelpers.ts diff --git a/tests/framework/ChromeCdpHelpers.ts b/tests/framework/ChromeCdpHelpers.ts new file mode 100644 index 00000000000..2a7715b75f3 --- /dev/null +++ b/tests/framework/ChromeCdpHelpers.ts @@ -0,0 +1,401 @@ +// eslint-disable-next-line import-x/no-nodejs-modules +import { execFileSync } from 'child_process'; +import { WebSocket as WsClient } from 'ws'; +import type { Context } from '@wdio/protocols'; +import type { AndroidDetailedContext } from 'webdriverio/build/types'; +import { getDriver } from './PlaywrightUtilities'; +import { createPlaywrightLogger } from './playwrightLogger.ts'; + +const logger = createPlaywrightLogger('ChromeCdpHelpers'); + +/** Host port for `adb forward` to Chrome's `@chrome_devtools_remote` socket. */ +const CDP_FORWARD_PORT = 9222; +const CDP_READY_TIMEOUT_MS = 20_000; +const DAPP_PAGE_TIMEOUT_MS = 30_000; +const POLL_MS = 500; + +interface ChromeContextInfo { + webSocketDebuggerUrl?: string; +} + +interface AndroidContextWithInfo extends AndroidDetailedContext { + info?: ChromeContextInfo; +} + +interface CdpTarget { + id?: string; + type?: string; + url?: string; + title?: string; + webSocketDebuggerUrl?: string; +} + +interface RawAppiumContext { + webviewName?: string; + webview?: string; + info?: ChromeContextInfo; +} + +class CdpSession { + private readonly ws: WsClient; + private nextId = 1; + private readonly pending = new Map< + number, + { resolve: (value: unknown) => void; reject: (error: Error) => void } + >(); + + private constructor(ws: WsClient) { + this.ws = ws; + this.ws.on('message', (data) => { + try { + const msg = JSON.parse(String(data)) as { + id?: number; + result?: { result?: { value?: unknown } }; + error?: { message?: string }; + }; + if (msg.id == null) return; + const waiter = this.pending.get(msg.id); + if (!waiter) return; + this.pending.delete(msg.id); + if (msg.error) { + waiter.reject(new Error(msg.error.message ?? 'CDP error')); + } else { + waiter.resolve(msg.result?.result?.value); + } + } catch { + // Ignore malformed CDP frames + } + }); + } + + static connect(wsUrl: string): Promise { + return new Promise((resolve, reject) => { + const ws = new WsClient(wsUrl); + const session = new CdpSession(ws); + ws.once('open', () => resolve(session)); + ws.once('error', (err) => reject(err)); + }); + } + + async evaluate(expression: string): Promise { + const id = this.nextId++; + const result = await new Promise((resolve, reject) => { + const timer = setTimeout(() => { + this.pending.delete(id); + reject(new Error(`CDP Runtime.evaluate timed out for id=${id}`)); + }, 15_000); + this.pending.set(id, { + resolve: (value) => { + clearTimeout(timer); + resolve(value); + }, + reject: (error) => { + clearTimeout(timer); + reject(error); + }, + }); + this.ws.send( + JSON.stringify({ + id, + method: 'Runtime.evaluate', + params: { + expression, + returnByValue: true, + awaitPromise: true, + }, + }), + ); + }); + return result as T; + } + + close(): void { + for (const [, waiter] of this.pending) { + waiter.reject(new Error('CDP session closed')); + } + this.pending.clear(); + this.ws.close(); + } +} + +/** + * Drive Android Chrome dapp pages via CDP, bypassing Appium Chromedriver. + * + * Emulator Chrome 113 + Appium `switchContext('WEBVIEW_chrome')` hangs during + * Chromedriver session creation even when `getContexts` already exposes a + * working `webSocketDebuggerUrl`. CDP uses that debugger channel directly. + */ +export default class ChromeCdpHelpers { + /** + * Forward host → device Chrome DevTools abstract socket. + */ + static ensureAdbForward(port = CDP_FORWARD_PORT): void { + try { + execFileSync('adb', ['forward', '--remove', `tcp:${port}`], { + stdio: 'pipe', + }); + } catch { + // No existing forward is fine. + } + execFileSync( + 'adb', + ['forward', `tcp:${port}`, 'localabstract:chrome_devtools_remote'], + { stdio: 'pipe' }, + ); + logger.debug(`ADB forwarded tcp:${port} → chrome_devtools_remote`); + } + + /** + * Wait until `[data-testid]` is visible in the Chrome tab for `dappUrl`. + */ + static async waitForTestId( + dappUrl: string, + testId: string, + timeoutMs = 15_000, + ): Promise { + await this.withCdpSession(dappUrl, async (session) => { + const deadline = Date.now() + timeoutMs; + while (Date.now() < deadline) { + const visible = await session.evaluate( + `(() => { + const el = document.querySelector('[data-testid=${JSON.stringify(testId)}]'); + if (!el) return false; + const style = window.getComputedStyle(el); + return style.visibility !== 'hidden' && style.display !== 'none'; + })()`, + ); + if (visible) { + return; + } + await new Promise((r) => setTimeout(r, POLL_MS)); + } + throw new Error( + `CDP: [data-testid="${testId}"] not visible within ${timeoutMs}ms on ${dappUrl}`, + ); + }); + } + + /** + * Click `[data-testid]` in the Chrome tab for `dappUrl`. + */ + static async clickTestId(dappUrl: string, testId: string): Promise { + await this.withCdpSession(dappUrl, async (session) => { + const clicked = await session.evaluate( + `(() => { + const el = document.querySelector('[data-testid=${JSON.stringify(testId)}]'); + if (!el) return false; + el.scrollIntoView({ block: 'center', inline: 'center' }); + el.click(); + return true; + })()`, + ); + if (!clicked) { + throw new Error( + `CDP: could not click [data-testid="${testId}"] on ${dappUrl}`, + ); + } + }); + } + + /** + * Wait for visibility then click. + */ + static async waitAndClickTestId( + dappUrl: string, + testId: string, + timeoutMs = 15_000, + ): Promise { + await this.waitForTestId(dappUrl, testId, timeoutMs); + await this.clickTestId(dappUrl, testId); + } + + private static async withCdpSession( + dappUrl: string, + actionFn: (session: CdpSession) => Promise, + ): Promise { + const endpoint = await this.resolveCdpHttpEndpoint(); + const target = await this.waitForCdpTarget(endpoint, dappUrl); + if (!target.webSocketDebuggerUrl) { + throw new Error( + `Chrome CDP target for ${dappUrl} has no webSocketDebuggerUrl`, + ); + } + logger.debug( + `CDP attached to ${target.url ?? target.title ?? target.id} via ${endpoint}`, + ); + const session = await CdpSession.connect(target.webSocketDebuggerUrl); + try { + return await actionFn(session); + } finally { + session.close(); + } + } + + private static async resolveCdpHttpEndpoint(): Promise { + const fromContexts = await this.tryEndpointFromAppiumContexts(); + if (fromContexts) { + return fromContexts; + } + + this.ensureAdbForward(); + const endpoint = `http://127.0.0.1:${CDP_FORWARD_PORT}`; + await this.waitForCdpEndpoint(endpoint); + return endpoint; + } + + private static async tryEndpointFromAppiumContexts(): Promise< + string | undefined + > { + try { + // Raw mobile:getContexts includes Chrome `info.webSocketDebuggerUrl` + // (WDIO getContexts may omit the nested info object). + const rawContexts = (await getDriver().execute( + 'mobile: getContexts', + )) as RawAppiumContext[]; + for (const ctx of rawContexts) { + const name = ctx.webviewName ?? ctx.webview ?? ''; + const wsUrl = ctx.info?.webSocketDebuggerUrl; + if (!wsUrl || !/chrome/i.test(name)) continue; + const http = this.httpEndpointFromWebSocketUrl(wsUrl); + if (http) { + await this.waitForCdpEndpoint(http); + logger.debug(`Using Appium-forwarded Chrome CDP at ${http}`); + return http; + } + } + + const contexts: (Context | AndroidContextWithInfo)[] = + await getDriver().getContexts({ returnDetailedContexts: true }); + for (const ctx of contexts) { + if (typeof ctx === 'string') continue; + const wsUrl = ctx.info?.webSocketDebuggerUrl; + if (!wsUrl || !/chrome/i.test(ctx.id)) continue; + const http = this.httpEndpointFromWebSocketUrl(wsUrl); + if (http) { + await this.waitForCdpEndpoint(http); + return http; + } + } + } catch (error) { + logger.debug( + 'Could not resolve CDP endpoint from Appium contexts:', + error instanceof Error ? error.message : String(error), + ); + } + return undefined; + } + + private static httpEndpointFromWebSocketUrl( + wsUrl: string, + ): string | undefined { + try { + const parsed = new URL(wsUrl); + const protocol = parsed.protocol === 'wss:' ? 'https:' : 'http:'; + return `${protocol}//${parsed.host}`; + } catch { + return undefined; + } + } + + private static async waitForCdpEndpoint(endpoint: string): Promise { + const deadline = Date.now() + CDP_READY_TIMEOUT_MS; + let lastError = ''; + while (Date.now() < deadline) { + try { + const response = await fetch(`${endpoint}/json/version`); + if (response.ok) { + return; + } + lastError = `HTTP ${response.status}`; + } catch (error) { + lastError = error instanceof Error ? error.message : String(error); + } + await new Promise((r) => setTimeout(r, POLL_MS)); + } + throw new Error( + `Chrome CDP endpoint ${endpoint} not ready within ${CDP_READY_TIMEOUT_MS}ms: ${lastError}`, + ); + } + + private static async waitForCdpTarget( + endpoint: string, + dappUrl: string, + ): Promise { + const deadline = Date.now() + DAPP_PAGE_TIMEOUT_MS; + while (Date.now() < deadline) { + try { + const response = await fetch(`${endpoint}/json/list`); + if (response.ok) { + const targets = (await response.json()) as CdpTarget[]; + const match = targets.find( + (t) => + (t.type === 'page' || !t.type) && + this.targetMatchesDapp(t, dappUrl), + ); + if (match?.webSocketDebuggerUrl) { + return match; + } + } + } catch { + // Keep polling + } + await new Promise((r) => setTimeout(r, POLL_MS)); + } + throw new Error( + `No Chrome CDP target matched ${dappUrl} within ${DAPP_PAGE_TIMEOUT_MS}ms`, + ); + } + + private static targetMatchesDapp( + target: CdpTarget, + dappUrl: string, + ): boolean { + if (target.url && this.urlsReferToSameDapp(target.url, dappUrl)) { + return true; + } + return ( + Boolean(target.title) && + /multichain api test dapp/i.test(target.title ?? '') && + /:8090\b/.test(dappUrl) + ); + } + + private static urlsReferToSameDapp( + candidateUrl: string, + dappUrl: string, + ): boolean { + if (!candidateUrl || candidateUrl === 'about:blank') { + return false; + } + if (candidateUrl.includes(dappUrl)) { + return true; + } + try { + const target = new URL(dappUrl); + const candidate = new URL(candidateUrl); + const hostAliases = new Set([ + '10.0.2.2', + 'localhost', + '127.0.0.1', + target.hostname, + candidate.hostname, + ]); + const sameHostFamily = + hostAliases.has(target.hostname) && hostAliases.has(candidate.hostname); + const samePort = + (candidate.port || defaultPort(candidate.protocol)) === + (target.port || defaultPort(target.protocol)); + const samePath = + candidate.pathname.replace(/\/$/, '') === + target.pathname.replace(/\/$/, ''); + return sameHostFamily && samePort && samePath; + } catch { + const port = dappUrl.match(/:(\d+)/)?.[1]; + return Boolean(port && candidateUrl.includes(`:${port}`)); + } + + function defaultPort(protocol: string): string { + return protocol === 'https:' ? '443' : '80'; + } + } +} diff --git a/tests/framework/services/providers/emulator/EmulatorConfigBuilder.ts b/tests/framework/services/providers/emulator/EmulatorConfigBuilder.ts index e4b5abbc366..c1b5588c36d 100644 --- a/tests/framework/services/providers/emulator/EmulatorConfigBuilder.ts +++ b/tests/framework/services/providers/emulator/EmulatorConfigBuilder.ts @@ -86,6 +86,9 @@ export class EmulatorConfigBuilder { 'appium:adbExecTimeout': androidAdbExecTimeoutMs, // Fail Chromedriver attach faster than the default when WebView is stuck. 'appium:androidWebviewConnectTimeout': 60_000, + // ChromeDriver 111+ rejects clients without this; hang looks like a stuck context switch. + 'appium:chromedriverArgs': ['--remote-allow-origins=*'], + 'appium:recreateChromeDriverSessions': true, } : { 'appium:bundleId': this.project.use.app?.appId, diff --git a/tests/smoke-appium/mm-connect/connection-multichain.spec.ts b/tests/smoke-appium/mm-connect/connection-multichain.spec.ts index 14a40b9b2c8..8b9e1932a61 100644 --- a/tests/smoke-appium/mm-connect/connection-multichain.spec.ts +++ b/tests/smoke-appium/mm-connect/connection-multichain.spec.ts @@ -2,16 +2,14 @@ import { test as appiumTest } from '../../framework/fixtures/playwright/index.js import { SmokeMMConnect } from '../../tags.js'; import { loginToAppPlaywright } from '../../flows/wallet.flow.js'; import { withFixtures } from '../../framework/fixtures/FixtureHelper.js'; -import BrowserPlaygroundDapp from '../../page-objects/MMConnect/BrowserPlaygroundDapp.js'; import AndroidScreenHelpers from '../../page-objects/MMConnect/AndroidScreenHelpers.js'; import DappConnectionModal from '../../page-objects/MMConnect/DappConnectionModal.js'; import PlaywrightContextHelpers from '../../framework/PlaywrightContextHelpers.js'; +import ChromeCdpHelpers from '../../framework/ChromeCdpHelpers.js'; import { DappServer, DappVariants, - PlaywrightGestures, TestDapps, - asPlaywrightElement, sleep, } from '../../framework/index.js'; import { @@ -27,6 +25,7 @@ import { switchToMobileBrowser, } from '../../flows/native-browser.flow.js'; import { multichainBrowserFixture } from './mm-connect-fixtures.js'; +import { MMConnectDappTestIds } from '../../selectors/MMConnect/MMConnectDapp.testIds.js'; const DAPP_PORT = 8090; @@ -36,6 +35,9 @@ const playgroundServer = new DappServer({ dappVariant: DappVariants.BROWSER_PLAYGROUND, }); +const scopeCardTestId = (scope: string): string => + `${MMConnectDappTestIds.SCOPE_CARD}-${scope.toLowerCase().replace(/:/g, '-')}`; + appiumTest.describe(SmokeMMConnect('Multichain browser connect'), () => { appiumTest.beforeAll(async () => { playgroundServer.setServerPort(DAPP_PORT); @@ -67,10 +69,12 @@ appiumTest.describe(SmokeMMConnect('Multichain browser connect'), () => { await navigateToDapp(DAPP_URL); }); - await PlaywrightContextHelpers.withWebAction(async () => { - await BrowserPlaygroundDapp.waitForConnectButtonVisible(15000); - await BrowserPlaygroundDapp.tapConnect(); - }, DAPP_URL); + // Emulator Chrome 113: Appium WEBVIEW_chrome switch hangs in Chromedriver + // session creation. Drive the dapp via CDP instead. + await ChromeCdpHelpers.waitAndClickTestId( + DAPP_URL, + MMConnectDappTestIds.CONNECT_BUTTON, + ); await PlaywrightContextHelpers.withNativeAction(async () => { await AndroidScreenHelpers.tapOpenDeeplinkWithMetaMask(); @@ -81,19 +85,18 @@ appiumTest.describe(SmokeMMConnect('Multichain browser connect'), () => { await switchToMobileBrowser(); await sleep(500); - await PlaywrightContextHelpers.withWebAction(async () => { - await BrowserPlaygroundDapp.assertMultichainConnected(true); - await PlaywrightGestures.scrollIntoView( - await asPlaywrightElement( - BrowserPlaygroundDapp.getScopeCard('eip155:1'), - ), - ); - await BrowserPlaygroundDapp.assertScopeCardVisible('eip155:1'); - }, DAPP_URL); - - await PlaywrightContextHelpers.withWebAction(async () => { - await BrowserPlaygroundDapp.tapDisconnect(); - }, DAPP_URL); + await ChromeCdpHelpers.waitForTestId( + DAPP_URL, + MMConnectDappTestIds.SCOPES_SECTION, + ); + await ChromeCdpHelpers.waitForTestId( + DAPP_URL, + scopeCardTestId('eip155:1'), + ); + await ChromeCdpHelpers.waitAndClickTestId( + DAPP_URL, + MMConnectDappTestIds.DISCONNECT_BUTTON, + ); }, ); }, diff --git a/tests/smoke-appium/mm-connect/utils.ts b/tests/smoke-appium/mm-connect/utils.ts index a834285f782..65a882c4e4c 100644 --- a/tests/smoke-appium/mm-connect/utils.ts +++ b/tests/smoke-appium/mm-connect/utils.ts @@ -59,14 +59,15 @@ export async function waitForDappServerReady( /** * Get the dapp URL for mobile browser access. - * Android emulator browser needs 10.0.2.2 to reach the host machine. + * Uses localhost on both platforms. On Android, pair with {@link setupAdbReverse} + * so the emulator reaches the host dapp server (preferred over 10.0.2.2 for + * stable URL / CDP matching). */ export function getDappUrlForBrowser( - platform: string, + _platform: string, port = DEFAULT_DAPP_PORT, ): string { - const host = platform === 'android' ? '10.0.2.2' : 'localhost'; - return `http://${host}:${port}`; + return `http://localhost:${port}`; } /** From a6b67b51f2638a2e6f8a1a83127ab25c50ad21a6 Mon Sep 17 00:00:00 2001 From: Chris Wilcox Date: Wed, 15 Jul 2026 15:54:29 -0700 Subject: [PATCH 08/15] fix(test): resolve Chrome CDP context info type for lint:tsc Co-authored-by: Cursor --- tests/framework/ChromeCdpHelpers.ts | 10 ++++++---- 1 file changed, 6 insertions(+), 4 deletions(-) diff --git a/tests/framework/ChromeCdpHelpers.ts b/tests/framework/ChromeCdpHelpers.ts index 2a7715b75f3..d09a2c4ed54 100644 --- a/tests/framework/ChromeCdpHelpers.ts +++ b/tests/framework/ChromeCdpHelpers.ts @@ -264,12 +264,14 @@ export default class ChromeCdpHelpers { } } - const contexts: (Context | AndroidContextWithInfo)[] = - await getDriver().getContexts({ returnDetailedContexts: true }); + const contexts = (await getDriver().getContexts({ + returnDetailedContexts: true, + })) as (Context | AndroidContextWithInfo)[]; for (const ctx of contexts) { if (typeof ctx === 'string') continue; - const wsUrl = ctx.info?.webSocketDebuggerUrl; - if (!wsUrl || !/chrome/i.test(ctx.id)) continue; + const detailed = ctx as AndroidContextWithInfo; + const wsUrl = detailed.info?.webSocketDebuggerUrl; + if (!wsUrl || !/chrome/i.test(detailed.id)) continue; const http = this.httpEndpointFromWebSocketUrl(wsUrl); if (http) { await this.waitForCdpEndpoint(http); From ba724d9f4208f07015bca1b2e47a9be31814c7ba Mon Sep 17 00:00:00 2001 From: Chris Wilcox Date: Wed, 15 Jul 2026 15:57:47 -0700 Subject: [PATCH 09/15] fix(test): drop arbitrary sleep after switchToMobileBrowser Co-authored-by: Cursor --- tests/smoke-appium/mm-connect/connection-multichain.spec.ts | 2 -- 1 file changed, 2 deletions(-) diff --git a/tests/smoke-appium/mm-connect/connection-multichain.spec.ts b/tests/smoke-appium/mm-connect/connection-multichain.spec.ts index 8b9e1932a61..f243cf9c329 100644 --- a/tests/smoke-appium/mm-connect/connection-multichain.spec.ts +++ b/tests/smoke-appium/mm-connect/connection-multichain.spec.ts @@ -10,7 +10,6 @@ import { DappServer, DappVariants, TestDapps, - sleep, } from '../../framework/index.js'; import { getDappUrlForBrowser, @@ -83,7 +82,6 @@ appiumTest.describe(SmokeMMConnect('Multichain browser connect'), () => { }); await switchToMobileBrowser(); - await sleep(500); await ChromeCdpHelpers.waitForTestId( DAPP_URL, From b747f6ad809051c576c75827776605c979b9ce07 Mon Sep 17 00:00:00 2001 From: Chris Wilcox Date: Wed, 15 Jul 2026 16:03:45 -0700 Subject: [PATCH 10/15] fix(test): prettier-format MMConnect multichain spec Co-authored-by: Cursor --- tests/smoke-appium/mm-connect/connection-multichain.spec.ts | 6 +----- 1 file changed, 1 insertion(+), 5 deletions(-) diff --git a/tests/smoke-appium/mm-connect/connection-multichain.spec.ts b/tests/smoke-appium/mm-connect/connection-multichain.spec.ts index f243cf9c329..08c9d49e126 100644 --- a/tests/smoke-appium/mm-connect/connection-multichain.spec.ts +++ b/tests/smoke-appium/mm-connect/connection-multichain.spec.ts @@ -6,11 +6,7 @@ import AndroidScreenHelpers from '../../page-objects/MMConnect/AndroidScreenHelp import DappConnectionModal from '../../page-objects/MMConnect/DappConnectionModal.js'; import PlaywrightContextHelpers from '../../framework/PlaywrightContextHelpers.js'; import ChromeCdpHelpers from '../../framework/ChromeCdpHelpers.js'; -import { - DappServer, - DappVariants, - TestDapps, -} from '../../framework/index.js'; +import { DappServer, DappVariants, TestDapps } from '../../framework/index.js'; import { getDappUrlForBrowser, setupAdbReverse, From b26ed7a4226afeb89ee05e9624af3715c5753629 Mon Sep 17 00:00:00 2001 From: Chris Wilcox Date: Wed, 15 Jul 2026 21:02:07 -0700 Subject: [PATCH 11/15] fix(test): harden MMConnect Android deeplink chooser handling Co-authored-by: Cursor --- tests/framework/ChromeCdpHelpers.ts | 9 +- .../MMConnect/AndroidScreenHelpers.ts | 118 +++++++++++++++++- .../mm-connect/connection-multichain.spec.ts | 2 + 3 files changed, 122 insertions(+), 7 deletions(-) diff --git a/tests/framework/ChromeCdpHelpers.ts b/tests/framework/ChromeCdpHelpers.ts index d09a2c4ed54..a3b75f4de73 100644 --- a/tests/framework/ChromeCdpHelpers.ts +++ b/tests/framework/ChromeCdpHelpers.ts @@ -185,7 +185,14 @@ export default class ChromeCdpHelpers { const el = document.querySelector('[data-testid=${JSON.stringify(testId)}]'); if (!el) return false; el.scrollIntoView({ block: 'center', inline: 'center' }); - el.click(); + // Prefer a trusted-style activation path for deeplink buttons. + if (typeof el.focus === 'function') el.focus(); + el.dispatchEvent(new MouseEvent('click', { + bubbles: true, + cancelable: true, + view: window, + })); + if (typeof el.click === 'function') el.click(); return true; })()`, ); diff --git a/tests/page-objects/MMConnect/AndroidScreenHelpers.ts b/tests/page-objects/MMConnect/AndroidScreenHelpers.ts index 68686ba7171..22997fac5b0 100644 --- a/tests/page-objects/MMConnect/AndroidScreenHelpers.ts +++ b/tests/page-objects/MMConnect/AndroidScreenHelpers.ts @@ -1,28 +1,134 @@ -import { encapsulatedAction } from '../../framework'; +import { encapsulatedAction, sleep, createLogger } from '../../framework'; import { encapsulated, EncapsulatedElementType, } from '../../framework/EncapsulatedElement'; +import { APP_PACKAGE_IDS } from '../../framework/Constants'; import PlaywrightMatchers from '../../framework/PlaywrightMatchers'; -import UnifiedGestures from '../../framework/UnifiedGestures'; +import PlaywrightGestures from '../../framework/PlaywrightGestures'; +import PlaywrightUtilities, { + getDriver, +} from '../../framework/PlaywrightUtilities'; +import { ConnectAccountBottomSheetSelectorsIDs } from '../../../app/components/Views/MultichainAccounts/shared/ConnectAccountBottomSheet.testIds'; + +const logger = createLogger({ + name: 'AndroidScreenHelpers', +}); + +/** Exact MetaMask label outside Chrome WebView (Android app chooser). */ +const CHOOSER_METAMASK_XPATHS = [ + '//android.widget.TextView[@text="MetaMask" and not(ancestor::android.webkit.WebView)]', + '//*[@resource-id="android:id/text1" and @text="MetaMask"]', + '//android.widget.Button[@text="MetaMask"]', + '//*[@content-desc="MetaMask"]', +]; + +const JUST_ONCE_XPATHS = [ + '//android.widget.Button[@resource-id="android:id/button_once"]', + '//android.widget.Button[@text="Just once"]', + '//*[@text="Just once"]', +]; + +const CHOOSER_TIMEOUT_MS = 20_000; +const POLL_MS = 500; class AndroidScreenHelpers { get openDeeplinkWithMetaMask(): EncapsulatedElementType { return encapsulated({ appium: () => - PlaywrightMatchers.getElementByXPath( - '//android.widget.TextView[@text="MetaMask"]', - ), + PlaywrightMatchers.getElementByXPath(CHOOSER_METAMASK_XPATHS[0]), }); } + /** + * After a dapp deeplink, select MetaMask from the Android app chooser. + * On CI emulators MetaMask is often the only handler, so Android skips the + * chooser and opens the app directly — treat that as success. Also collapse + * the status bar so Play services heads-up toasts do not block taps. + */ async tapOpenDeeplinkWithMetaMask(): Promise { await encapsulatedAction({ appium: async () => { - await UnifiedGestures.waitAndTap(this.openDeeplinkWithMetaMask); + PlaywrightUtilities.collapseStatusBar(); + + const deadline = Date.now() + CHOOSER_TIMEOUT_MS; + while (Date.now() < deadline) { + PlaywrightUtilities.collapseStatusBar(); + + if (await this.isMetaMaskForeground()) { + logger.debug( + 'MetaMask already foreground after deeplink; skipping chooser', + ); + return; + } + + for (const xpath of CHOOSER_METAMASK_XPATHS) { + try { + const option = await PlaywrightMatchers.getElementByXPath(xpath); + if (await option.isVisible()) { + await PlaywrightGestures.waitAndTap(option, { + timeout: 3_000, + delay: 200, + }); + await this.tapJustOnceIfPresent(); + return; + } + } catch { + // Try next selector + } + } + + await sleep(POLL_MS); + } + + throw new Error( + `Android MetaMask deeplink chooser not shown (and MetaMask not foreground) after ${CHOOSER_TIMEOUT_MS}ms`, + ); }, }); } + + private async isMetaMaskForeground(): Promise { + try { + const currentPackage = (await getDriver().execute( + 'mobile: getCurrentPackage', + )) as string; + if ( + currentPackage === APP_PACKAGE_IDS.ANDROID || + currentPackage?.startsWith(`${APP_PACKAGE_IDS.ANDROID}.`) + ) { + return true; + } + } catch { + // Ignore package probe failures + } + + try { + const connectButton = await PlaywrightMatchers.getElementById( + ConnectAccountBottomSheetSelectorsIDs.CONNECT_BUTTON, + ); + return await connectButton.isVisible(); + } catch { + return false; + } + } + + private async tapJustOnceIfPresent(): Promise { + for (const xpath of JUST_ONCE_XPATHS) { + try { + const button = await PlaywrightMatchers.getElementByXPath(xpath); + if (await button.isVisible()) { + await PlaywrightGestures.waitAndTap(button, { + timeout: 2_000, + delay: 100, + }); + return; + } + } catch { + // Not present + } + } + } } export default new AndroidScreenHelpers(); diff --git a/tests/smoke-appium/mm-connect/connection-multichain.spec.ts b/tests/smoke-appium/mm-connect/connection-multichain.spec.ts index 08c9d49e126..2c872200b65 100644 --- a/tests/smoke-appium/mm-connect/connection-multichain.spec.ts +++ b/tests/smoke-appium/mm-connect/connection-multichain.spec.ts @@ -6,6 +6,7 @@ import AndroidScreenHelpers from '../../page-objects/MMConnect/AndroidScreenHelp import DappConnectionModal from '../../page-objects/MMConnect/DappConnectionModal.js'; import PlaywrightContextHelpers from '../../framework/PlaywrightContextHelpers.js'; import ChromeCdpHelpers from '../../framework/ChromeCdpHelpers.js'; +import PlaywrightUtilities from '../../framework/PlaywrightUtilities.js'; import { DappServer, DappVariants, TestDapps } from '../../framework/index.js'; import { getDappUrlForBrowser, @@ -66,6 +67,7 @@ appiumTest.describe(SmokeMMConnect('Multichain browser connect'), () => { // Emulator Chrome 113: Appium WEBVIEW_chrome switch hangs in Chromedriver // session creation. Drive the dapp via CDP instead. + PlaywrightUtilities.collapseStatusBar(); await ChromeCdpHelpers.waitAndClickTestId( DAPP_URL, MMConnectDappTestIds.CONNECT_BUTTON, From 086baa00e99b2e6bc952d51a97e9db7ccc9c7756 Mon Sep 17 00:00:00 2001 From: Chris Wilcox Date: Wed, 15 Jul 2026 21:41:33 -0700 Subject: [PATCH 12/15] fix(test): open MMConnect wallet via captured CDP deeplink Synthetic JS clicks never reach MetaMask because the SDK defers metamask:// navigation; use trusted CDP mouse input and Appium deepLink. Co-authored-by: Cursor --- tests/framework/ChromeCdpHelpers.ts | 262 +++++++++++++++--- .../mm-connect/connection-multichain.spec.ts | 6 +- 2 files changed, 226 insertions(+), 42 deletions(-) diff --git a/tests/framework/ChromeCdpHelpers.ts b/tests/framework/ChromeCdpHelpers.ts index a3b75f4de73..eccdb04a266 100644 --- a/tests/framework/ChromeCdpHelpers.ts +++ b/tests/framework/ChromeCdpHelpers.ts @@ -3,7 +3,8 @@ import { execFileSync } from 'child_process'; import { WebSocket as WsClient } from 'ws'; import type { Context } from '@wdio/protocols'; import type { AndroidDetailedContext } from 'webdriverio/build/types'; -import { getDriver } from './PlaywrightUtilities'; +import { APP_PACKAGE_IDS } from './Constants.ts'; +import { getDriver, executeMobileDeepLink } from './PlaywrightUtilities'; import { createPlaywrightLogger } from './playwrightLogger.ts'; const logger = createPlaywrightLogger('ChromeCdpHelpers'); @@ -12,6 +13,7 @@ const logger = createPlaywrightLogger('ChromeCdpHelpers'); const CDP_FORWARD_PORT = 9222; const CDP_READY_TIMEOUT_MS = 20_000; const DAPP_PAGE_TIMEOUT_MS = 30_000; +const DEEPLINK_CAPTURE_TIMEOUT_MS = 8_000; const POLL_MS = 500; interface ChromeContextInfo { @@ -36,6 +38,16 @@ interface RawAppiumContext { info?: ChromeContextInfo; } +interface CdpEvaluateResult { + result?: { value?: unknown; type?: string }; + exceptionDetails?: { text?: string; exception?: { description?: string } }; +} + +interface ElementCenter { + x: number; + y: number; +} + class CdpSession { private readonly ws: WsClient; private nextId = 1; @@ -50,7 +62,7 @@ class CdpSession { try { const msg = JSON.parse(String(data)) as { id?: number; - result?: { result?: { value?: unknown } }; + result?: unknown; error?: { message?: string }; }; if (msg.id == null) return; @@ -60,7 +72,7 @@ class CdpSession { if (msg.error) { waiter.reject(new Error(msg.error.message ?? 'CDP error')); } else { - waiter.resolve(msg.result?.result?.value); + waiter.resolve(msg.result); } } catch { // Ignore malformed CDP frames @@ -77,12 +89,15 @@ class CdpSession { }); } - async evaluate(expression: string): Promise { + async send( + method: string, + params: Record = {}, + ): Promise { const id = this.nextId++; - const result = await new Promise((resolve, reject) => { + return new Promise((resolve, reject) => { const timer = setTimeout(() => { this.pending.delete(id); - reject(new Error(`CDP Runtime.evaluate timed out for id=${id}`)); + reject(new Error(`CDP ${method} timed out for id=${id}`)); }, 15_000); this.pending.set(id, { resolve: (value) => { @@ -94,19 +109,30 @@ class CdpSession { reject(error); }, }); - this.ws.send( - JSON.stringify({ - id, - method: 'Runtime.evaluate', - params: { - expression, - returnByValue: true, - awaitPromise: true, - }, - }), - ); + this.ws.send(JSON.stringify({ id, method, params })); }); - return result as T; + } + + async evaluate( + expression: string, + options: { userGesture?: boolean } = {}, + ): Promise { + const result = (await this.send('Runtime.evaluate', { + expression, + returnByValue: true, + awaitPromise: true, + userGesture: options.userGesture ?? false, + })) as CdpEvaluateResult; + + if (result.exceptionDetails) { + const detail = + result.exceptionDetails.exception?.description ?? + result.exceptionDetails.text ?? + 'Runtime.evaluate failed'; + throw new Error(detail); + } + + return result.result?.value as T; } close(): void { @@ -176,31 +202,13 @@ export default class ChromeCdpHelpers { } /** - * Click `[data-testid]` in the Chrome tab for `dappUrl`. + * Click `[data-testid]` in the Chrome tab for `dappUrl` using a real CDP + * mouse input (trusted user activation). Synthetic JS clicks are not enough + * for MM Connect: the SDK opens `metamask://` inside `setTimeout(…, 10)`. */ static async clickTestId(dappUrl: string, testId: string): Promise { await this.withCdpSession(dappUrl, async (session) => { - const clicked = await session.evaluate( - `(() => { - const el = document.querySelector('[data-testid=${JSON.stringify(testId)}]'); - if (!el) return false; - el.scrollIntoView({ block: 'center', inline: 'center' }); - // Prefer a trusted-style activation path for deeplink buttons. - if (typeof el.focus === 'function') el.focus(); - el.dispatchEvent(new MouseEvent('click', { - bubbles: true, - cancelable: true, - view: window, - })); - if (typeof el.click === 'function') el.click(); - return true; - })()`, - ); - if (!clicked) { - throw new Error( - `CDP: could not click [data-testid="${testId}"] on ${dappUrl}`, - ); - } + await this.dispatchTrustedClick(session, testId); }); } @@ -216,6 +224,180 @@ export default class ChromeCdpHelpers { await this.clickTestId(dappUrl, testId); } + /** + * Click a connect control that should open MetaMask via deeplink. + * + * Captures the SDK's `metamask://` / app-link URL (navigation is often blocked + * without lasting user activation after `setTimeout`) and opens it with + * Appium `mobile: deepLink` scoped to the MetaMask package. + */ + static async waitAndClickTestIdOpeningMetaMask( + dappUrl: string, + testId: string, + timeoutMs = 15_000, + ): Promise { + await this.waitForTestId(dappUrl, testId, timeoutMs); + await this.withCdpSession(dappUrl, async (session) => { + await this.installDeeplinkCapture(session); + await this.dispatchTrustedClick(session, testId); + + const deeplink = await this.waitForCapturedDeeplink(session); + logger.debug( + `Opening captured MM Connect deeplink via Appium: ${deeplink}`, + ); + await executeMobileDeepLink(deeplink, { + package: APP_PACKAGE_IDS.ANDROID, + }); + }); + } + + private static async installDeeplinkCapture( + session: CdpSession, + ): Promise { + await session.evaluate(`(() => { + const w = window; + if (w.__mmCdpDeeplinkHookInstalled) { + w.__mmCdpDeeplinks = []; + return true; + } + w.__mmCdpDeeplinks = []; + w.__mmCdpDeeplinkHookInstalled = true; + const push = (url) => { + if (url == null) return; + const value = String(url); + if (!value || value === 'about:blank') return; + w.__mmCdpDeeplinks.push(value); + }; + try { + const desc = Object.getOwnPropertyDescriptor(Location.prototype, 'href'); + if (desc && desc.set && desc.get) { + Object.defineProperty(Location.prototype, 'href', { + configurable: true, + enumerable: desc.enumerable, + get() { return desc.get.call(this); }, + set(v) { + push(v); + return desc.set.call(this, v); + }, + }); + } + } catch (_) { + // Location.prototype may be non-configurable on some Chromium builds. + } + try { + const loc = window.location; + const origAssign = loc.assign.bind(loc); + loc.assign = (url) => { + push(url); + return origAssign(url); + }; + const origReplace = loc.replace.bind(loc); + loc.replace = (url) => { + push(url); + return origReplace(url); + }; + } catch (_) { + // location.assign/replace may be non-writable. + } + try { + const origOpen = window.open.bind(window); + window.open = (url, ...args) => { + push(url); + return origOpen(url, ...args); + }; + } catch (_) { + // ignore + } + try { + const origClick = HTMLAnchorElement.prototype.click; + HTMLAnchorElement.prototype.click = function (...args) { + push(this.href); + return origClick.apply(this, args); + }; + } catch (_) { + // ignore + } + return true; + })()`); + } + + private static async waitForCapturedDeeplink( + session: CdpSession, + ): Promise { + const deadline = Date.now() + DEEPLINK_CAPTURE_TIMEOUT_MS; + while (Date.now() < deadline) { + const urls = await session.evaluate( + `Array.isArray(window.__mmCdpDeeplinks) ? window.__mmCdpDeeplinks.slice() : []`, + ); + const match = urls.find((url) => this.isMetaMaskConnectUrl(url)); + if (match) { + return match; + } + await new Promise((r) => setTimeout(r, POLL_MS)); + } + throw new Error( + `CDP: MetaMask connect deeplink was not captured within ${DEEPLINK_CAPTURE_TIMEOUT_MS}ms`, + ); + } + + private static isMetaMaskConnectUrl(url: string): boolean { + try { + if (url.startsWith('metamask://')) { + return true; + } + const parsed = new URL(url); + return ( + parsed.hostname === 'metamask.app.link' || + parsed.hostname.endsWith('.metamask.app.link') + ); + } catch { + return /metamask:\/\//i.test(url) || /metamask\.app\.link/i.test(url); + } + } + + private static async dispatchTrustedClick( + session: CdpSession, + testId: string, + ): Promise { + const center = await session.evaluate( + `(() => { + const el = document.querySelector('[data-testid=${JSON.stringify(testId)}]'); + if (!el) return null; + el.scrollIntoView({ block: 'center', inline: 'center' }); + const rect = el.getBoundingClientRect(); + if (rect.width <= 0 || rect.height <= 0) return null; + return { + x: rect.left + rect.width / 2, + y: rect.top + rect.height / 2, + }; + })()`, + ); + if (!center) { + throw new Error( + `CDP: could not resolve click target for [data-testid="${testId}"]`, + ); + } + + // Real input events preserve transient user activation across the SDK's + // setTimeout(..., 10) before window.location.href = metamask://... + await session.send('Input.dispatchMouseEvent', { + type: 'mousePressed', + x: center.x, + y: center.y, + button: 'left', + buttons: 1, + clickCount: 1, + }); + await session.send('Input.dispatchMouseEvent', { + type: 'mouseReleased', + x: center.x, + y: center.y, + button: 'left', + buttons: 0, + clickCount: 1, + }); + } + private static async withCdpSession( dappUrl: string, actionFn: (session: CdpSession) => Promise, diff --git a/tests/smoke-appium/mm-connect/connection-multichain.spec.ts b/tests/smoke-appium/mm-connect/connection-multichain.spec.ts index 2c872200b65..edc89846a64 100644 --- a/tests/smoke-appium/mm-connect/connection-multichain.spec.ts +++ b/tests/smoke-appium/mm-connect/connection-multichain.spec.ts @@ -66,9 +66,11 @@ appiumTest.describe(SmokeMMConnect('Multichain browser connect'), () => { }); // Emulator Chrome 113: Appium WEBVIEW_chrome switch hangs in Chromedriver - // session creation. Drive the dapp via CDP instead. + // session creation. Drive the dapp via CDP instead. Connect also + // captures the SDK metamask:// URL and opens it via mobile: deepLink + // (Chrome blocks deferred location.href without lasting activation). PlaywrightUtilities.collapseStatusBar(); - await ChromeCdpHelpers.waitAndClickTestId( + await ChromeCdpHelpers.waitAndClickTestIdOpeningMetaMask( DAPP_URL, MMConnectDappTestIds.CONNECT_BUTTON, ); From 658ce5ecdcb83cb3da03fe3724952c57b29308a9 Mon Sep 17 00:00:00 2001 From: Chris Wilcox Date: Wed, 15 Jul 2026 22:18:57 -0700 Subject: [PATCH 13/15] fix(test): capture MMConnect deeplink from SDK session_request Location.href hooks miss Chrome-sealed setters; rebuild metamask:// from the SDK session payload and wait longer for transport session_request. Co-authored-by: Cursor --- tests/framework/ChromeCdpHelpers.ts | 188 +++++++++++++++++----------- 1 file changed, 115 insertions(+), 73 deletions(-) diff --git a/tests/framework/ChromeCdpHelpers.ts b/tests/framework/ChromeCdpHelpers.ts index eccdb04a266..1abe2b23474 100644 --- a/tests/framework/ChromeCdpHelpers.ts +++ b/tests/framework/ChromeCdpHelpers.ts @@ -13,7 +13,8 @@ const logger = createPlaywrightLogger('ChromeCdpHelpers'); const CDP_FORWARD_PORT = 9222; const CDP_READY_TIMEOUT_MS = 20_000; const DAPP_PAGE_TIMEOUT_MS = 30_000; -const DEEPLINK_CAPTURE_TIMEOUT_MS = 8_000; +/** SDK opens metamask:// only after transport `session_request` (can take several seconds). */ +const DEEPLINK_CAPTURE_TIMEOUT_MS = 30_000; const POLL_MS = 500; interface ChromeContextInfo { @@ -43,10 +44,7 @@ interface CdpEvaluateResult { exceptionDetails?: { text?: string; exception?: { description?: string } }; } -interface ElementCenter { - x: number; - y: number; -} +type CdpEventHandler = (params: Record) => void; class CdpSession { private readonly ws: WsClient; @@ -55,6 +53,7 @@ class CdpSession { number, { resolve: (value: unknown) => void; reject: (error: Error) => void } >(); + private readonly eventHandlers = new Map>(); private constructor(ws: WsClient) { this.ws = ws; @@ -62,9 +61,20 @@ class CdpSession { try { const msg = JSON.parse(String(data)) as { id?: number; + method?: string; + params?: Record; result?: unknown; error?: { message?: string }; }; + if (msg.method) { + const handlers = this.eventHandlers.get(msg.method); + if (handlers) { + for (const handler of handlers) { + handler(msg.params ?? {}); + } + } + return; + } if (msg.id == null) return; const waiter = this.pending.get(msg.id); if (!waiter) return; @@ -89,6 +99,15 @@ class CdpSession { }); } + on(method: string, handler: CdpEventHandler): void { + let handlers = this.eventHandlers.get(method); + if (!handlers) { + handlers = new Set(); + this.eventHandlers.set(method, handlers); + } + handlers.add(handler); + } + async send( method: string, params: Record = {}, @@ -140,6 +159,7 @@ class CdpSession { waiter.reject(new Error('CDP session closed')); } this.pending.clear(); + this.eventHandlers.clear(); this.ws.close(); } } @@ -227,9 +247,12 @@ export default class ChromeCdpHelpers { /** * Click a connect control that should open MetaMask via deeplink. * - * Captures the SDK's `metamask://` / app-link URL (navigation is often blocked - * without lasting user activation after `setTimeout`) and opens it with - * Appium `mobile: deepLink` scoped to the MetaMask package. + * The Multichain SDK emits `display_uri` / builds `metamask://connect/mwp?…` + * only after transport `session_request` — not synchronously on click. Chrome + * often seals `Location.prototype`, so we capture via: + * - CDP Page navigation events + * - reconstructing the URL when the SDK JSON.stringifies the session request + * then open it with Appium `mobile: deepLink`. */ static async waitAndClickTestIdOpeningMetaMask( dappUrl: string, @@ -238,10 +261,31 @@ export default class ChromeCdpHelpers { ): Promise { await this.waitForTestId(dappUrl, testId, timeoutMs); await this.withCdpSession(dappUrl, async (session) => { + const captured: string[] = []; + const remember = (url: unknown) => { + if (url == null) return; + const value = String(url); + if (!value || value === 'about:blank') return; + if (!captured.includes(value)) { + captured.push(value); + } + }; + + await session.send('Page.enable'); + session.on('Page.frameRequestedNavigation', (params) => { + remember(params.url); + }); + session.on('Page.windowOpen', (params) => { + remember(params.url); + }); + session.on('Page.navigatedWithinDocument', (params) => { + remember(params.url); + }); + await this.installDeeplinkCapture(session); await this.dispatchTrustedClick(session, testId); - const deeplink = await this.waitForCapturedDeeplink(session); + const deeplink = await this.waitForCapturedDeeplink(session, captured); logger.debug( `Opening captured MM Connect deeplink via Appium: ${deeplink}`, ); @@ -254,20 +298,46 @@ export default class ChromeCdpHelpers { private static async installDeeplinkCapture( session: CdpSession, ): Promise { + // Capture URL when the SDK builds it (createConnectionDeeplink), which + // happens on session_request — before (and even if) location.href is blocked. await session.evaluate(`(() => { const w = window; - if (w.__mmCdpDeeplinkHookInstalled) { - w.__mmCdpDeeplinks = []; - return true; - } w.__mmCdpDeeplinks = []; - w.__mmCdpDeeplinkHookInstalled = true; const push = (url) => { if (url == null) return; const value = String(url); if (!value || value === 'about:blank') return; - w.__mmCdpDeeplinks.push(value); + if (!w.__mmCdpDeeplinks.includes(value)) { + w.__mmCdpDeeplinks.push(value); + } + }; + + const toMwpUrl = (scheme, payloadJson) => { + const bytes = new TextEncoder().encode(payloadJson); + let binary = ''; + bytes.forEach((b) => { binary += String.fromCharCode(b); }); + const b64 = btoa(binary); + return scheme + '/mwp?p=' + encodeURIComponent(b64) + '&c=1'; + }; + + const origStringify = JSON.stringify.bind(JSON); + JSON.stringify = (value, ...args) => { + const out = origStringify(value, ...args); + try { + if ( + value && + typeof value === 'object' && + value.sessionRequest && + value.metadata && + value.metadata.dapp + ) { + push(toMwpUrl('metamask://connect', out)); + push(toMwpUrl('https://metamask.app.link/connect', out)); + } + } catch (_) {} + return out; }; + try { const desc = Object.getOwnPropertyDescriptor(Location.prototype, 'href'); if (desc && desc.set && desc.get) { @@ -281,62 +351,56 @@ export default class ChromeCdpHelpers { }, }); } - } catch (_) { - // Location.prototype may be non-configurable on some Chromium builds. - } - try { - const loc = window.location; - const origAssign = loc.assign.bind(loc); - loc.assign = (url) => { - push(url); - return origAssign(url); - }; - const origReplace = loc.replace.bind(loc); - loc.replace = (url) => { - push(url); - return origReplace(url); - }; - } catch (_) { - // location.assign/replace may be non-writable. - } + } catch (_) {} + try { const origOpen = window.open.bind(window); window.open = (url, ...args) => { push(url); return origOpen(url, ...args); }; - } catch (_) { - // ignore - } + } catch (_) {} + try { const origClick = HTMLAnchorElement.prototype.click; HTMLAnchorElement.prototype.click = function (...args) { push(this.href); return origClick.apply(this, args); }; - } catch (_) { - // ignore - } + } catch (_) {} + return true; })()`); } private static async waitForCapturedDeeplink( session: CdpSession, + cdpCaptured: string[], ): Promise { const deadline = Date.now() + DEEPLINK_CAPTURE_TIMEOUT_MS; while (Date.now() < deadline) { - const urls = await session.evaluate( + const pageUrls = await session.evaluate( `Array.isArray(window.__mmCdpDeeplinks) ? window.__mmCdpDeeplinks.slice() : []`, ); - const match = urls.find((url) => this.isMetaMaskConnectUrl(url)); + const all = [...cdpCaptured, ...pageUrls]; + // Prefer native scheme for Appium deepLink package scoping. + const native = all.find((url) => url.startsWith('metamask://')); + if (native) { + return native; + } + const match = all.find((url) => this.isMetaMaskConnectUrl(url)); if (match) { return match; } await new Promise((r) => setTimeout(r, POLL_MS)); } + + const pageUrls = await session.evaluate( + `Array.isArray(window.__mmCdpDeeplinks) ? window.__mmCdpDeeplinks.slice() : []`, + ); throw new Error( - `CDP: MetaMask connect deeplink was not captured within ${DEEPLINK_CAPTURE_TIMEOUT_MS}ms`, + `CDP: MetaMask connect deeplink was not captured within ${DEEPLINK_CAPTURE_TIMEOUT_MS}ms` + + ` (cdp=${JSON.stringify(cdpCaptured)}; page=${JSON.stringify(pageUrls)})`, ); } @@ -359,43 +423,21 @@ export default class ChromeCdpHelpers { session: CdpSession, testId: string, ): Promise { - const center = await session.evaluate( + // Single activation only — a second click while CONNECTING fails the dapp. + // userGesture:true matches the path that previously reached CONNECTING in CI. + const clicked = await session.evaluate( `(() => { const el = document.querySelector('[data-testid=${JSON.stringify(testId)}]'); - if (!el) return null; + if (!el || typeof el.click !== 'function') return false; el.scrollIntoView({ block: 'center', inline: 'center' }); - const rect = el.getBoundingClientRect(); - if (rect.width <= 0 || rect.height <= 0) return null; - return { - x: rect.left + rect.width / 2, - y: rect.top + rect.height / 2, - }; + el.click(); + return true; })()`, + { userGesture: true }, ); - if (!center) { - throw new Error( - `CDP: could not resolve click target for [data-testid="${testId}"]`, - ); + if (!clicked) { + throw new Error(`CDP: could not click [data-testid="${testId}"]`); } - - // Real input events preserve transient user activation across the SDK's - // setTimeout(..., 10) before window.location.href = metamask://... - await session.send('Input.dispatchMouseEvent', { - type: 'mousePressed', - x: center.x, - y: center.y, - button: 'left', - buttons: 1, - clickCount: 1, - }); - await session.send('Input.dispatchMouseEvent', { - type: 'mouseReleased', - x: center.x, - y: center.y, - button: 'left', - buttons: 0, - clickCount: 1, - }); } private static async withCdpSession( From db35b776e3206219c94ab162bb18e46fd767f222 Mon Sep 17 00:00:00 2001 From: Chris Wilcox Date: Wed, 15 Jul 2026 22:20:14 -0700 Subject: [PATCH 14/15] fix(test): tap lowercase metamask in Android Connect chooser CI video shows Choose activity with "metamask"; stop blocking on CDP URL capture and match the chooser case-insensitively. Co-authored-by: Cursor --- tests/page-objects/MMConnect/AndroidScreenHelpers.ts | 12 +++++++----- .../mm-connect/connection-multichain.spec.ts | 8 ++++---- 2 files changed, 11 insertions(+), 9 deletions(-) diff --git a/tests/page-objects/MMConnect/AndroidScreenHelpers.ts b/tests/page-objects/MMConnect/AndroidScreenHelpers.ts index 22997fac5b0..6c392f971ab 100644 --- a/tests/page-objects/MMConnect/AndroidScreenHelpers.ts +++ b/tests/page-objects/MMConnect/AndroidScreenHelpers.ts @@ -15,12 +15,14 @@ const logger = createLogger({ name: 'AndroidScreenHelpers', }); -/** Exact MetaMask label outside Chrome WebView (Android app chooser). */ +/** MetaMask label in Android app chooser (CI often shows lowercase "metamask"). */ const CHOOSER_METAMASK_XPATHS = [ - '//android.widget.TextView[@text="MetaMask" and not(ancestor::android.webkit.WebView)]', - '//*[@resource-id="android:id/text1" and @text="MetaMask"]', - '//android.widget.Button[@text="MetaMask"]', - '//*[@content-desc="MetaMask"]', + `//android.widget.TextView[translate(@text, 'ABCDEFGHIJKLMNOPQRSTUVWXYZ', 'abcdefghijklmnopqrstuvwxyz')='metamask' and not(ancestor::android.webkit.WebView)]`, + `//*[@resource-id="android:id/text1" and translate(@text, 'ABCDEFGHIJKLMNOPQRSTUVWXYZ', 'abcdefghijklmnopqrstuvwxyz')='metamask']`, + `//android.widget.Button[translate(@text, 'ABCDEFGHIJKLMNOPQRSTUVWXYZ', 'abcdefghijklmnopqrstuvwxyz')='metamask']`, + `//*[translate(@content-desc, 'ABCDEFGHIJKLMNOPQRSTUVWXYZ', 'abcdefghijklmnopqrstuvwxyz')='metamask']`, + // Resolve-info row: text may be nested under list item + `//*[@resource-id="android:id/list"]//*[translate(@text, 'ABCDEFGHIJKLMNOPQRSTUVWXYZ', 'abcdefghijklmnopqrstuvwxyz')='metamask']`, ]; const JUST_ONCE_XPATHS = [ diff --git a/tests/smoke-appium/mm-connect/connection-multichain.spec.ts b/tests/smoke-appium/mm-connect/connection-multichain.spec.ts index edc89846a64..722304dd4e7 100644 --- a/tests/smoke-appium/mm-connect/connection-multichain.spec.ts +++ b/tests/smoke-appium/mm-connect/connection-multichain.spec.ts @@ -66,11 +66,11 @@ appiumTest.describe(SmokeMMConnect('Multichain browser connect'), () => { }); // Emulator Chrome 113: Appium WEBVIEW_chrome switch hangs in Chromedriver - // session creation. Drive the dapp via CDP instead. Connect also - // captures the SDK metamask:// URL and opens it via mobile: deepLink - // (Chrome blocks deferred location.href without lasting activation). + // session creation. Drive the dapp via CDP instead. CI video shows the + // Android "Choose activity" (Chrome vs metamask) after Connect — tap it + // next; do not block on JS deeplink capture. PlaywrightUtilities.collapseStatusBar(); - await ChromeCdpHelpers.waitAndClickTestIdOpeningMetaMask( + await ChromeCdpHelpers.waitAndClickTestId( DAPP_URL, MMConnectDappTestIds.CONNECT_BUTTON, ); From 0ebaf19076141a6cbea7894e5f70dc69b1552a7d Mon Sep 17 00:00:00 2001 From: Chris Wilcox Date: Wed, 15 Jul 2026 23:09:25 -0700 Subject: [PATCH 15/15] fix(test): require connect sheet after MMConnect deeplink CI opened MetaMask home without a pending request because package foreground alone skipped the chooser; deepLink the session URL and wait for connect-button. Co-authored-by: Cursor --- .../MMConnect/AndroidScreenHelpers.ts | 42 ++++++++----------- .../MMConnect/DappConnectionModal.ts | 4 +- .../mm-connect/connection-multichain.spec.ts | 12 +++--- 3 files changed, 27 insertions(+), 31 deletions(-) diff --git a/tests/page-objects/MMConnect/AndroidScreenHelpers.ts b/tests/page-objects/MMConnect/AndroidScreenHelpers.ts index 6c392f971ab..9765bb1581d 100644 --- a/tests/page-objects/MMConnect/AndroidScreenHelpers.ts +++ b/tests/page-objects/MMConnect/AndroidScreenHelpers.ts @@ -3,12 +3,9 @@ import { encapsulated, EncapsulatedElementType, } from '../../framework/EncapsulatedElement'; -import { APP_PACKAGE_IDS } from '../../framework/Constants'; import PlaywrightMatchers from '../../framework/PlaywrightMatchers'; import PlaywrightGestures from '../../framework/PlaywrightGestures'; -import PlaywrightUtilities, { - getDriver, -} from '../../framework/PlaywrightUtilities'; +import PlaywrightUtilities from '../../framework/PlaywrightUtilities'; import { ConnectAccountBottomSheetSelectorsIDs } from '../../../app/components/Views/MultichainAccounts/shared/ConnectAccountBottomSheet.testIds'; const logger = createLogger({ @@ -44,9 +41,10 @@ class AndroidScreenHelpers { /** * After a dapp deeplink, select MetaMask from the Android app chooser. - * On CI emulators MetaMask is often the only handler, so Android skips the - * chooser and opens the app directly — treat that as success. Also collapse - * the status bar so Play services heads-up toasts do not block taps. + * + * Do NOT treat "MetaMask package is foreground" alone as success — CI often + * lands on the wallet home screen without a pending connection request. + * Success is the connect sheet (`connect-button`) or an explicit chooser tap. */ async tapOpenDeeplinkWithMetaMask(): Promise { await encapsulatedAction({ @@ -57,9 +55,9 @@ class AndroidScreenHelpers { while (Date.now() < deadline) { PlaywrightUtilities.collapseStatusBar(); - if (await this.isMetaMaskForeground()) { + if (await this.isConnectSheetVisible()) { logger.debug( - 'MetaMask already foreground after deeplink; skipping chooser', + 'MetaMask connect sheet already visible; skipping chooser', ); return; } @@ -73,6 +71,14 @@ class AndroidScreenHelpers { delay: 200, }); await this.tapJustOnceIfPresent(); + // Wait briefly for the connect sheet after chooser selection. + const sheetDeadline = Date.now() + 10_000; + while (Date.now() < sheetDeadline) { + if (await this.isConnectSheetVisible()) { + return; + } + await sleep(POLL_MS); + } return; } } catch { @@ -84,27 +90,13 @@ class AndroidScreenHelpers { } throw new Error( - `Android MetaMask deeplink chooser not shown (and MetaMask not foreground) after ${CHOOSER_TIMEOUT_MS}ms`, + `Android MetaMask deeplink chooser / connect sheet not shown after ${CHOOSER_TIMEOUT_MS}ms`, ); }, }); } - private async isMetaMaskForeground(): Promise { - try { - const currentPackage = (await getDriver().execute( - 'mobile: getCurrentPackage', - )) as string; - if ( - currentPackage === APP_PACKAGE_IDS.ANDROID || - currentPackage?.startsWith(`${APP_PACKAGE_IDS.ANDROID}.`) - ) { - return true; - } - } catch { - // Ignore package probe failures - } - + private async isConnectSheetVisible(): Promise { try { const connectButton = await PlaywrightMatchers.getElementById( ConnectAccountBottomSheetSelectorsIDs.CONNECT_BUTTON, diff --git a/tests/page-objects/MMConnect/DappConnectionModal.ts b/tests/page-objects/MMConnect/DappConnectionModal.ts index c8cefeb1bb3..d7f680995cf 100644 --- a/tests/page-objects/MMConnect/DappConnectionModal.ts +++ b/tests/page-objects/MMConnect/DappConnectionModal.ts @@ -95,11 +95,13 @@ class DappConnectionModal { async tapConnectButton({ shouldCooldown = false, timeToCooldown = 1000, + timeout = 15_000, }: { shouldCooldown?: boolean; timeToCooldown?: number; + timeout?: number; } = {}): Promise { - await UnifiedGestures.waitAndTap(this.connectButton); + await UnifiedGestures.waitAndTap(this.connectButton, { timeout }); if (shouldCooldown) { await sleep(timeToCooldown); } diff --git a/tests/smoke-appium/mm-connect/connection-multichain.spec.ts b/tests/smoke-appium/mm-connect/connection-multichain.spec.ts index 722304dd4e7..1a1e58678e1 100644 --- a/tests/smoke-appium/mm-connect/connection-multichain.spec.ts +++ b/tests/smoke-appium/mm-connect/connection-multichain.spec.ts @@ -66,11 +66,11 @@ appiumTest.describe(SmokeMMConnect('Multichain browser connect'), () => { }); // Emulator Chrome 113: Appium WEBVIEW_chrome switch hangs in Chromedriver - // session creation. Drive the dapp via CDP instead. CI video shows the - // Android "Choose activity" (Chrome vs metamask) after Connect — tap it - // next; do not block on JS deeplink capture. + // session creation. Drive the dapp via CDP; capture metamask:// from the + // SDK session payload and open it with package-scoped mobile: deepLink + // so we land on the connect sheet (not wallet home via bare app focus). PlaywrightUtilities.collapseStatusBar(); - await ChromeCdpHelpers.waitAndClickTestId( + await ChromeCdpHelpers.waitAndClickTestIdOpeningMetaMask( DAPP_URL, MMConnectDappTestIds.CONNECT_BUTTON, ); @@ -78,7 +78,9 @@ appiumTest.describe(SmokeMMConnect('Multichain browser connect'), () => { await PlaywrightContextHelpers.withNativeAction(async () => { await AndroidScreenHelpers.tapOpenDeeplinkWithMetaMask(); await unlockIfLockScreenVisible(); - await DappConnectionModal.tapConnectButton(); + await DappConnectionModal.tapConnectButton({ + timeout: 30_000, + }); }); await switchToMobileBrowser();