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/flows/native-browser.flow.ts b/tests/flows/native-browser.flow.ts index 9af180636b6..2ca31957fe5 100644 --- a/tests/flows/native-browser.flow.ts +++ b/tests/flows/native-browser.flow.ts @@ -16,16 +16,28 @@ 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 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); - await PlaywrightGestures.waitAndTap(element); + const dismissControl = await PlaywrightMatchers.getElementByText(text); + await PlaywrightGestures.waitAndTap(dismissControl); return; } catch { // This text not found, try next @@ -35,11 +47,12 @@ 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 () => { - const element = await PlaywrightMatchers.getElementByText('No thanks'); - await PlaywrightGestures.waitAndTap(element); + const noThanks = await PlaywrightMatchers.getElementByText('No thanks'); + await PlaywrightGestures.waitAndTap(noThanks); }; /** @@ -86,6 +99,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 chromeTarget = await probe(); + if (await chromeTarget.isVisible()) { + 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 +148,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 +180,76 @@ 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_VIEW_INTENT_SETTLE_MS)); + 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 + } + 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/ChromeCdpHelpers.ts b/tests/framework/ChromeCdpHelpers.ts new file mode 100644 index 00000000000..1abe2b23474 --- /dev/null +++ b/tests/framework/ChromeCdpHelpers.ts @@ -0,0 +1,634 @@ +// 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 { APP_PACKAGE_IDS } from './Constants.ts'; +import { getDriver, executeMobileDeepLink } 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; +/** 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 { + 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; +} + +interface CdpEvaluateResult { + result?: { value?: unknown; type?: string }; + exceptionDetails?: { text?: string; exception?: { description?: string } }; +} + +type CdpEventHandler = (params: Record) => void; + +class CdpSession { + private readonly ws: WsClient; + private nextId = 1; + private readonly pending = new Map< + number, + { resolve: (value: unknown) => void; reject: (error: Error) => void } + >(); + private readonly eventHandlers = new Map>(); + + private constructor(ws: WsClient) { + this.ws = ws; + this.ws.on('message', (data) => { + 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; + this.pending.delete(msg.id); + if (msg.error) { + waiter.reject(new Error(msg.error.message ?? 'CDP error')); + } else { + waiter.resolve(msg.result); + } + } 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)); + }); + } + + 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 = {}, + ): Promise { + const id = this.nextId++; + return new Promise((resolve, reject) => { + const timer = setTimeout(() => { + this.pending.delete(id); + reject(new Error(`CDP ${method} 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, params })); + }); + } + + 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 { + for (const [, waiter] of this.pending) { + waiter.reject(new Error('CDP session closed')); + } + this.pending.clear(); + this.eventHandlers.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` 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) => { + await this.dispatchTrustedClick(session, testId); + }); + } + + /** + * 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); + } + + /** + * Click a connect control that should open MetaMask via deeplink. + * + * 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, + testId: string, + timeoutMs = 15_000, + ): 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, captured); + 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 { + // 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; + w.__mmCdpDeeplinks = []; + const push = (url) => { + if (url == null) return; + const value = String(url); + if (!value || value === 'about:blank') return; + 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) { + 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 (_) {} + + try { + const origOpen = window.open.bind(window); + window.open = (url, ...args) => { + push(url); + return origOpen(url, ...args); + }; + } catch (_) {} + + try { + const origClick = HTMLAnchorElement.prototype.click; + HTMLAnchorElement.prototype.click = function (...args) { + push(this.href); + return origClick.apply(this, args); + }; + } 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 pageUrls = await session.evaluate( + `Array.isArray(window.__mmCdpDeeplinks) ? window.__mmCdpDeeplinks.slice() : []`, + ); + 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=${JSON.stringify(cdpCaptured)}; page=${JSON.stringify(pageUrls)})`, + ); + } + + 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 { + // 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 || typeof el.click !== 'function') return false; + el.scrollIntoView({ block: 'center', inline: 'center' }); + el.click(); + return true; + })()`, + { userGesture: true }, + ); + if (!clicked) { + throw new Error(`CDP: could not click [data-testid="${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 = (await getDriver().getContexts({ + returnDetailedContexts: true, + })) as (Context | AndroidContextWithInfo)[]; + for (const ctx of contexts) { + if (typeof ctx === 'string') 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); + 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/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 9dadd1c391e..181f9cc8812 100644 --- a/tests/framework/PlaywrightUtilities.ts +++ b/tests/framework/PlaywrightUtilities.ts @@ -455,6 +455,72 @@ 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). + * 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, '\\"'); + 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}`); + } + + /** + * 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/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/page-objects/MMConnect/AndroidScreenHelpers.ts b/tests/page-objects/MMConnect/AndroidScreenHelpers.ts index 68686ba7171..9765bb1581d 100644 --- a/tests/page-objects/MMConnect/AndroidScreenHelpers.ts +++ b/tests/page-objects/MMConnect/AndroidScreenHelpers.ts @@ -1,28 +1,128 @@ -import { encapsulatedAction } from '../../framework'; +import { encapsulatedAction, sleep, createLogger } from '../../framework'; import { encapsulated, EncapsulatedElementType, } from '../../framework/EncapsulatedElement'; import PlaywrightMatchers from '../../framework/PlaywrightMatchers'; -import UnifiedGestures from '../../framework/UnifiedGestures'; +import PlaywrightGestures from '../../framework/PlaywrightGestures'; +import PlaywrightUtilities from '../../framework/PlaywrightUtilities'; +import { ConnectAccountBottomSheetSelectorsIDs } from '../../../app/components/Views/MultichainAccounts/shared/ConnectAccountBottomSheet.testIds'; + +const logger = createLogger({ + name: 'AndroidScreenHelpers', +}); + +/** MetaMask label in Android app chooser (CI often shows lowercase "metamask"). */ +const CHOOSER_METAMASK_XPATHS = [ + `//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 = [ + '//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. + * + * 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({ 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.isConnectSheetVisible()) { + logger.debug( + 'MetaMask connect sheet already visible; 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(); + // 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 { + // Try next selector + } + } + + await sleep(POLL_MS); + } + + throw new Error( + `Android MetaMask deeplink chooser / connect sheet not shown after ${CHOOSER_TIMEOUT_MS}ms`, + ); }, }); } + + private async isConnectSheetVisible(): Promise { + 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/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/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..1a1e58678e1 --- /dev/null +++ b/tests/smoke-appium/mm-connect/connection-multichain.spec.ts @@ -0,0 +1,104 @@ +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 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 PlaywrightUtilities from '../../framework/PlaywrightUtilities.js'; +import { DappServer, DappVariants, TestDapps } 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'; +import { MMConnectDappTestIds } from '../../selectors/MMConnect/MMConnectDapp.testIds.js'; + +const DAPP_PORT = 8090; + +const playgroundServer = new DappServer({ + dappCounter: 0, + rootDirectory: TestDapps[DappVariants.BROWSER_PLAYGROUND].dappPath, + 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); + 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); + }); + + // Emulator Chrome 113: Appium WEBVIEW_chrome switch hangs in Chromedriver + // 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.waitAndClickTestIdOpeningMetaMask( + DAPP_URL, + MMConnectDappTestIds.CONNECT_BUTTON, + ); + + await PlaywrightContextHelpers.withNativeAction(async () => { + await AndroidScreenHelpers.tapOpenDeeplinkWithMetaMask(); + await unlockIfLockScreenVisible(); + await DappConnectionModal.tapConnectButton({ + timeout: 30_000, + }); + }); + + await switchToMobileBrowser(); + + 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/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..65a882c4e4c --- /dev/null +++ b/tests/smoke-appium/mm-connect/utils.ts @@ -0,0 +1,98 @@ +/* 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. + * 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, + port = DEFAULT_DAPP_PORT, +): string { + return `http://localhost:${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,