diff --git a/tests/flows/browser.flow.ts b/tests/flows/browser.flow.ts index ea5b62549484..f3a5ab3a6af2 100644 --- a/tests/flows/browser.flow.ts +++ b/tests/flows/browser.flow.ts @@ -29,15 +29,73 @@ import { getDappUrl } from '../framework/fixtures/FixtureUtils'; * @throws {Error} Throws an error if the test dapp fails to load after a certain number of attempts. */ export const waitForTestDappToLoad = async (): Promise => { + const MAX_RETRIES = 3; + const WEBVIEW_LOAD_TIMEOUT_MS = 30_000; + if (FrameworkDetector.isAppium()) { await Assertions.expectElementToBeVisible( PlaywrightMatchers.getElementByText(getDappUrl(0)), { description: 'Browser URL bar should show test dapp URL' }, ); - return; - } - const MAX_RETRIES = 3; + // URL-bar-only is not enough: Test Dapp buttons stay disabled until the + // page JS runs and the provider connects. Mirror Detox by waiting for + // page chrome before WebView taps. + for (let attempt = 1; attempt <= MAX_RETRIES; attempt++) { + try { + if (PlatformDetector.isAndroidAppium()) { + // Native accessibility exposes WebView text on Android CI. + await Assertions.expectElementToBeVisible( + Matchers.getElementByID(BrowserViewSelectorsIDs.BROWSER_WEBVIEW_ID), + { + description: 'Browser WebView native container', + timeout: WEBVIEW_LOAD_TIMEOUT_MS, + }, + ); + await Assertions.expectTextDisplayed('E2E Test Dapp', { + timeout: WEBVIEW_LOAD_TIMEOUT_MS, + description: 'Test Dapp page title should be visible', + }); + return; + } + + // iOS: HTML title/logo are not reliably in the native tree. Use WebView + // element waits, then always return to NATIVE_APP so later native taps + // (e.g. close browser) do not fail with accessibility-id errors. + try { + await Assertions.expectElementToBeVisible(TestDApp.testDappFoxLogo, { + description: 'Test Dapp Fox Logo should be visible', + timeout: WEBVIEW_LOAD_TIMEOUT_MS, + }); + await Assertions.expectElementToBeVisible( + TestDApp.testDappPageTitle, + { + description: 'Test Dapp Page Title should be visible', + timeout: WEBVIEW_LOAD_TIMEOUT_MS, + }, + ); + } finally { + await PlaywrightContextHelpers.switchToNativeContext().catch( + () => undefined, + ); + } + return; + } catch (error) { + await PlaywrightContextHelpers.switchToNativeContext().catch( + () => undefined, + ); + if (attempt === MAX_RETRIES) { + throw new Error( + `Test dapp failed to load after ${MAX_RETRIES} attempts: ${ + error instanceof Error ? error.message : 'Unknown error' + }`, + ); + } + } + } + + throw new Error('Test dapp failed to become fully interactive'); + } for (let attempt = 1; attempt <= MAX_RETRIES; attempt++) { try { diff --git a/tests/flows/confirmations.flow.ts b/tests/flows/confirmations.flow.ts index c4dc51e948a8..3e567010d31e 100644 --- a/tests/flows/confirmations.flow.ts +++ b/tests/flows/confirmations.flow.ts @@ -2,7 +2,7 @@ import Assertions from '../framework/Assertions'; import ChromeCdpHelpers from '../framework/ChromeCdpHelpers'; import { getDappUrl } from '../framework/fixtures/FixtureUtils'; import { PlatformDetector } from '../framework/PlatformLocator'; -import Utilities from '../framework/Utilities'; +import Utilities, { sleep } from '../framework/Utilities'; import WebView from '../framework/WebView'; import Browser from '../page-objects/Browser/BrowserView'; import ConnectBottomSheet from '../page-objects/Browser/ConnectBottomSheet'; @@ -19,6 +19,7 @@ import SwitchAccountModal from '../page-objects/wallet/SwitchAccountModal'; import ActivitiesView from '../page-objects/Transactions/ActivitiesView'; import TabBarComponent from '../page-objects/wallet/TabBarComponent'; import WalletView from '../page-objects/wallet/WalletView'; +import { TestDappSelectorsWebIDs } from '../selectors/Browser/TestDapp.selectors'; import { navigateToBrowserView, waitForTestDappToLoad } from './browser.flow'; import { dismissPushNotificationExistingUserSheet, @@ -30,7 +31,11 @@ const LOCAL_CHAIN_CAIP = 'eip155:1337'; const SMART_ACCOUNT_UPGRADED_ACTIVITY = 'Smart account upgraded'; const SMART_ACCOUNT_UPGRADING_ACTIVITY = 'Upgrading smart account'; const ANDROID_CONFIRM_SHEET_TIMEOUT_MS = 60_000; -const ANDROID_CONFIRM_POLL_MS = 3_000; +/** Per-tap wait for the confirmation sheet (gas estimation can exceed a few seconds). */ +const ANDROID_CONFIRM_AFTER_TAP_MS = 15_000; +const TEST_DAPP_PROVIDER_READY_TIMEOUT_MS = 30_000; +const TEST_DAPP_ACCOUNTS_HYDRATE_TIMEOUT_MS = 30_000; +const TEST_DAPP_BUTTON_ENABLED_TIMEOUT_MS = 30_000; const DAPP_BUTTON_READY_TIMEOUT_MS = 20_000; const DAPP_BUTTON_READY_POLL_MS = 500; /** Re-run the dapp's contract binding if it is still missing after this long. */ @@ -46,6 +51,216 @@ const CONTRACT_ADDRESS_ELEMENT_IDS = [ 'erc1155TokenAddresses', ]; +export { + LOCAL_CHAIN_CAIP, + SMART_ACCOUNT_UPGRADED_ACTIVITY, + SMART_ACCOUNT_UPGRADING_ACTIVITY, +}; + +/** + * Wait until the Test Dapp has an injected provider with a selected account. + * Connected fixtures still need provider injection before action buttons enable. + */ +const waitForTestDappProviderReady = async (pageUrl: string): Promise => { + try { + await Utilities.waitUntil( + async () => { + const ready = await ChromeCdpHelpers.evaluateInWebView( + pageUrl, + `(() => { + const eth = window.ethereum; + if (!eth) return false; + if ( + typeof eth.selectedAddress === 'string' && + eth.selectedAddress.length > 0 + ) { + return true; + } + const accountsEl = document.getElementById(${JSON.stringify( + TestDappSelectorsWebIDs.ACCOUNTS_TEXT, + )}); + const accountsText = (accountsEl?.textContent || '') + .replace(/^Accounts:\\s*/i, '') + .trim(); + return accountsText.length > 0; + })()`, + ); + return Boolean(ready); + }, + { timeout: TEST_DAPP_PROVIDER_READY_TIMEOUT_MS, interval: 500 }, + ); + } catch (error) { + throw new Error( + `Test dapp provider not ready within ${TEST_DAPP_PROVIDER_READY_TIMEOUT_MS}ms` + + ` (window.ethereum / selectedAddress / #${TestDappSelectorsWebIDs.ACCOUNTS_TEXT}): ${ + error instanceof Error ? error.message : String(error) + }`, + ); + } +}; + +interface TestDappConnectionState { + accountsUi: string; + ethAccountCount: number; + connectLabel: string; + activeProviderName: string; + hasEip6963UseButton: boolean; + hasProviderRequest: boolean; +} + +const readTestDappConnectionState = async ( + pageUrl: string, +): Promise => + ChromeCdpHelpers.evaluateInWebView( + pageUrl, + `(async () => { + const accountsEl = document.getElementById(${JSON.stringify( + TestDappSelectorsWebIDs.ACCOUNTS_TEXT, + )}); + const connectEl = document.getElementById(${JSON.stringify( + TestDappSelectorsWebIDs.CONNECT_BUTTON, + )}); + const activeNameEl = document.getElementById(${JSON.stringify( + TestDappSelectorsWebIDs.ACTIVE_PROVIDER_NAME, + )}); + const eip6963Button = document.querySelector( + ${JSON.stringify(`#${TestDappSelectorsWebIDs.PROVIDERS_CONTAINER} button`)}, + ); + const eth = window.ethereum; + let ethAccounts = []; + if (eth && typeof eth.request === 'function') { + try { + ethAccounts = await eth.request({ method: 'eth_accounts' }); + } catch (_error) { + ethAccounts = []; + } + } + return { + accountsUi: (accountsEl?.textContent || '') + .replace(/^Accounts:\\s*/i, '') + .trim(), + ethAccountCount: Array.isArray(ethAccounts) ? ethAccounts.length : 0, + connectLabel: (connectEl?.textContent || '').trim(), + activeProviderName: (activeNameEl?.textContent || '').trim(), + hasEip6963UseButton: Boolean(eip6963Button), + hasProviderRequest: Boolean(eth && typeof eth.request === 'function'), + }; + })()`, + ); + +const isTestDappAccountsHydrated = ( + state: TestDappConnectionState | null, +): boolean => + Boolean( + state?.hasProviderRequest && + state.activeProviderName.length > 0 && + state.ethAccountCount > 0 && + state.accountsUi.length > 0, + ); + +/** + * Test Dapp `initialize()` races EIP-6963 announce vs `providerDetails.length`. + * After reload, Active Provider often stays empty (CI: UUID/Name blank) so + * Connect's `globalContext.provider.request` is undefined. Click "Use MetaMask" + * once the EIP-6963 button renders — that runs `setActiveProviderDetail`. + */ +const ensureTestDappActiveProvider = async (pageUrl: string): Promise => { + const deadline = Date.now() + TEST_DAPP_ACCOUNTS_HYDRATE_TIMEOUT_MS; + let lastClickAt = 0; + + while (Date.now() < deadline) { + const state = await readTestDappConnectionState(pageUrl); + if (state?.activeProviderName) { + return; + } + + if (state?.hasEip6963UseButton && Date.now() - lastClickAt >= 1_000) { + lastClickAt = Date.now(); + await ChromeCdpHelpers.evaluateInWebView( + pageUrl, + `(() => { + const btn = document.querySelector( + ${JSON.stringify(`#${TestDappSelectorsWebIDs.PROVIDERS_CONTAINER} button`)}, + ); + if (!btn || typeof btn.click !== 'function') return false; + btn.click(); + return true; + })()`, + ); + } + + await sleep(400); + } + + const finalState = await readTestDappConnectionState(pageUrl); + throw new Error( + `Test dapp active provider never selected within ${TEST_DAPP_ACCOUNTS_HYDRATE_TIMEOUT_MS}ms` + + ` (state=${JSON.stringify(finalState)})`, + ); +}; + +/** + * After Active Provider is set, ensure accounts land in `#accounts` via Connect + * (`eth_requestAccounts` → `handleNewAccounts`). Approve the sheet only if it + * appears (fixture permissions often resolve without UI). + */ +const ensureTestDappAccountsHydrated = async ( + pageUrl: string, +): Promise => { + await ensureTestDappActiveProvider(pageUrl); + + const deadline = Date.now() + TEST_DAPP_ACCOUNTS_HYDRATE_TIMEOUT_MS; + let lastConnectClickAt = 0; + + while (Date.now() < deadline) { + const state = await readTestDappConnectionState(pageUrl); + if (isTestDappAccountsHydrated(state)) { + return; + } + + if ( + state?.activeProviderName && + state.connectLabel !== 'Connected' && + Date.now() - lastConnectClickAt >= 1_500 + ) { + lastConnectClickAt = Date.now(); + await ChromeCdpHelpers.evaluateInWebView( + pageUrl, + `(() => { + const el = document.getElementById(${JSON.stringify( + TestDappSelectorsWebIDs.CONNECT_BUTTON, + )}); + if (!el || typeof el.click !== 'function') return false; + if ('disabled' in el && Boolean(el.disabled)) return false; + el.click(); + return true; + })()`, + ); + + try { + await Assertions.expectElementToBeVisible( + ConnectBottomSheet.connectButton, + { + timeout: 2_500, + description: 'Connect account sheet after Test Dapp Connect', + }, + ); + await ConnectBottomSheet.tapConnectButton(); + } catch { + // Already permitted — eth_requestAccounts resolves without a sheet. + } + } + + await sleep(400); + } + + const finalState = await readTestDappConnectionState(pageUrl); + throw new Error( + `Test dapp accounts never hydrated within ${TEST_DAPP_ACCOUNTS_HYDRATE_TIMEOUT_MS}ms` + + ` (state=${JSON.stringify(finalState)})`, + ); +}; + interface TestDappButtonState { href: string; documentReady: boolean; @@ -106,7 +321,7 @@ const waitForTestDappButtonReady = async ( pageUrl: string, buttonId: string, timeoutMs = DAPP_BUTTON_READY_TIMEOUT_MS, -): Promise => { +): Promise<{ state: TestDappButtonState | null; reloaded: boolean }> => { const startedAt = Date.now(); let state: TestDappButtonState | null = null; let reloaded = false; @@ -140,17 +355,16 @@ const waitForTestDappButtonReady = async ( // Return the last observed state so the caller can include diagnostics. } - return state; -}; - -export { - LOCAL_CHAIN_CAIP, - SMART_ACCOUNT_UPGRADED_ACTIVITY, - SMART_ACCOUNT_UPGRADING_ACTIVITY, + return { state, reloaded }; }; /** * Tap a test-dapp WebView button and wait for the confirmation sheet. + * + * Android: hydrate Active Provider + accounts, wait for button/contract + * readiness, then tap. Wait long enough after each tap for gas estimation — + * re-tapping every few seconds rejects in-flight `eth_sendTransaction` + * ("Creation Failed" on the Test Dapp). */ const tapTestDappButtonAndWaitForConfirm = async ( buttonId: string, @@ -159,35 +373,79 @@ const tapTestDappButtonAndWaitForConfirm = async ( const pageUrl = getDappUrl(0); const confirmTimeoutMs = 30_000; + // iOS Appium: provider + enabled control only (async eth_accounts hydrate + // is unreliable via evaluateInWebView on iOS). + if (PlatformDetector.isIOSAppium()) { + await waitForTestDappProviderReady(pageUrl); + await ChromeCdpHelpers.waitForElementEnabledByIdInWebView( + pageUrl, + buttonId, + TEST_DAPP_BUTTON_ENABLED_TIMEOUT_MS, + ); + } + if (PlatformDetector.isAndroidAppium()) { + // Provider + real `#accounts` hydration (EIP-6963 Use MetaMask → Connect). + // Do not fake `globalConnectionChange` alone — that enables buttons without + // `src.provider`, so taps no-op ("Creation Failed" / never opens confirm). let dismissedPushSheet = false; + let attempt = 0; let lastState: TestDappButtonState | null = null; try { await Utilities.executeWithRetry( async () => { - lastState = await waitForTestDappButtonReady(pageUrl, buttonId); + attempt += 1; + await ensureTestDappAccountsHydrated(pageUrl); + // Document ready + ethereum + enabled button; for `?contract=` pages, + // also wait until contract addresses are bound (reload once if not). + let buttonReady = await waitForTestDappButtonReady(pageUrl, buttonId); + lastState = buttonReady.state; + // Reload clears Active Provider / `#accounts`. Re-hydrate and wait + // again so we do not tap a post-reload page that looks "ready" without + // a connected provider (same EIP-6963 race this PR fixes). + if (buttonReady.reloaded) { + await ensureTestDappAccountsHydrated(pageUrl); + buttonReady = await waitForTestDappButtonReady(pageUrl, buttonId); + lastState = buttonReady.state; + } await WebView.tapById(buttonId, { pageUrl, description, + // First attempt matches signature smokes (CDP then native). Later + // attempts force native in case CDP reported a false-success click. + preferNative: attempt > 1, }); try { - await FooterActions.waitForConfirmButton(ANDROID_CONFIRM_POLL_MS); + await FooterActions.waitForConfirmButton( + ANDROID_CONFIRM_AFTER_TAP_MS, + ); } catch (error) { if (!dismissedPushSheet) { dismissedPushSheet = true; await dismissPushNotificationExistingUserSheet(); + // Push sheet may have covered confirm — check again before re-tap. + try { + await FooterActions.waitForConfirmButton(5_000); + return; + } catch { + // Fall through to retry with another tap. + } } throw error; } }, - { timeout: ANDROID_CONFIRM_SHEET_TIMEOUT_MS }, + { + timeout: ANDROID_CONFIRM_SHEET_TIMEOUT_MS, + // Keep retries sparse so we do not stampede eth_sendTransaction. + interval: 2_000, + }, ); } catch (error) { + const detail = error instanceof Error ? error.message : String(error); throw new Error( - `Confirmation sheet never opened after tapping #${buttonId} (${description}); ` + - `last dapp state=${JSON.stringify(lastState)}; cause=${ - error instanceof Error ? error.message : String(error) - }`, + `Confirmation sheet never opened after tapping ${buttonId} ` + + `(${description}) within ${ANDROID_CONFIRM_SHEET_TIMEOUT_MS}ms` + + ` (last dapp state=${JSON.stringify(lastState)}; ${detail})`, ); } return; diff --git a/tests/framework/AndroidWebViewNative.test.ts b/tests/framework/AndroidWebViewNative.test.ts index 2bccc6332efe..5d3c5853d615 100644 --- a/tests/framework/AndroidWebViewNative.test.ts +++ b/tests/framework/AndroidWebViewNative.test.ts @@ -250,6 +250,21 @@ describe('tapAndroidWebId CDP path', () => { await tapAndroidWebId('connectbip32', { pageUrl }); expect(AndroidWebViewCdpHelpers.tapElementById).not.toHaveBeenCalled(); }); + + it('skips CDP tap when preferNative is set', async () => { + mockDriverWithFindSequence(['hit']); + (AndroidWebViewCdpHelpers.tapElementById as jest.Mock).mockResolvedValue( + true, + ); + + await tapAndroidWebId('connectbip32', { + pageUrl, + preferNative: true, + }); + + expect(AndroidWebViewCdpHelpers.tapElementById).not.toHaveBeenCalled(); + expect(PlaywrightGestures.waitAndTap).toHaveBeenCalled(); + }); }); describe('fillAndroidWebId CDP path', () => { diff --git a/tests/framework/AndroidWebViewNative.ts b/tests/framework/AndroidWebViewNative.ts index 3480c535f464..f62c903597ba 100644 --- a/tests/framework/AndroidWebViewNative.ts +++ b/tests/framework/AndroidWebViewNative.ts @@ -31,6 +31,12 @@ export interface AndroidWebViewScrollOptions { export type AndroidWebViewTapOptions = AndroidWebViewScrollOptions & { description?: string; timeout?: number; + /** + * Skip CDP tap short-circuit; use native UiAutomator wait-until-enabled + tap. + * Prefer for confirmation flows where CDP can report click success without + * MetaMask opening the confirmation sheet. + */ + preferNative?: boolean; }; /** Fill/read share scroll options; Android Appium never uses WebView DOM context. */ @@ -243,7 +249,11 @@ export async function tapAndroidWebId( ): Promise { logger.debug(options.description ?? `Android native WebView tap: ${webId}`); - if (options.pageUrl && isAndroidWebViewCdpEnabled()) { + if ( + !options.preferNative && + options.pageUrl && + isAndroidWebViewCdpEnabled() + ) { const cdpTapped = await AndroidWebViewCdpHelpers.tapElementById(webId, { pageUrl: options.pageUrl, }); diff --git a/tests/framework/WebView.ts b/tests/framework/WebView.ts index d7c530038dcc..4b4d5f2dbc2c 100644 --- a/tests/framework/WebView.ts +++ b/tests/framework/WebView.ts @@ -24,6 +24,9 @@ export type WebViewByIdOptions = AndroidWebViewScrollOptions & { /** Native WebView container testID. Defaults to the in-app browser WebView. */ webviewId?: string; description?: string; + timeout?: number; + /** Android Appium: skip CDP tap; use native UiAutomator path. */ + preferNative?: boolean; }; export type { AndroidWebViewScrollOptions, AndroidWebViewTapOptions }; diff --git a/tests/selectors/Browser/TestDapp.selectors.ts b/tests/selectors/Browser/TestDapp.selectors.ts index c124c0066378..d406ea549d2d 100644 --- a/tests/selectors/Browser/TestDapp.selectors.ts +++ b/tests/selectors/Browser/TestDapp.selectors.ts @@ -1,6 +1,9 @@ export const TestDappSelectorsWebIDs = { ACCOUNTS_TEXT: 'accounts', CHAIN_ID_TEXT: 'chainId', + ACTIVE_PROVIDER_NAME: 'activeProviderName', + PROVIDERS_CONTAINER: 'providers', + USE_WINDOW_PROVIDER_BUTTON: 'useWindowProviderButton', TEST_DAPP_FOX_LOGO: 'mm-logo', TEST_DAPP_HEADING_TITLE: 'logo-text', APPROVE_ERC_20_TOKENS_BUTTON_ID: 'approveTokens',