Skip to content

Commit 78a2d32

Browse files
javiergarciaveracursoragentclaude
authored
test: MMQA-1995: Migrate smoke/networks Detox tests to Appium (#32804)
## **Description** Migrates the Detox smoke tests in `tests/smoke/networks/` to Playwright + Appium equivalents under `tests/smoke-appium/networks/`, and removes the duplicate Detox specs once parity is achieved. **Motivation:** Continue the incremental Detox → Appium migration so network smoke coverage runs in the Appium CI pipeline on both Android and iOS. **Key changes:** - Add Appium smoke specs for `SmokeNetworkAbstractions` (`network-manager2`) and `SmokeWalletPlatform` (`homepage-sections-network-filter`) - Add Appium smoke CI jobs for both tags on Android and iOS (`appium-network-abstractions-*`, `appium-wallet-platform-*`) - Extend shared E2E framework for Android Appium browser flows: URL bar navigation, native accessibility for test-dapp network picker interactions, Playwright label assertions, and browser tab grid handling - Remove migrated Detox specs from `tests/smoke/networks/` ### Migrated specs | Former Detox spec | Appium spec | Smoke tag | |---|---|---| | `tests/smoke/networks/network-manager2.spec.ts` | `tests/smoke-appium/networks/network-manager2.spec.ts` | `SmokeNetworkAbstractions` | | `tests/smoke/networks/homepage-sections-network-filter.spec.ts` | `tests/smoke-appium/networks/homepage-sections-network-filter.spec.ts` | `SmokeWalletPlatform` | ### Test coverage (unchanged scenarios) **SmokeNetworkAbstractions — Network Manager** - Preserves existing enabled networks when adding a network via dapp **SmokeWalletPlatform — Homepage Tokens Section - Network Filter** - Navigates from homepage tokens section to tokens full view - Filters tokens by selected network in tokens full view - Shows all tokens on homepage regardless of network filter set in tokens full view ## **Changelog** CHANGELOG entry: null ## **Related issues** Refs: MMQA-1995 ## **Manual testing steps** ```gherkin Feature: Networks smoke Appium migration Scenario: network-manager2 preserves enabled networks when adding via dapp (Android) Given the Appium smoke Android environment is set up with a running emulator When I run the network-manager2 Appium smoke spec Then the test passes end-to-end including dapp network picker, connect modal, and wallet network verification Scenario: homepage-sections-network-filter smoke scenarios (Android) Given the Appium smoke Android environment is set up with a running emulator When I run the homepage-sections-network-filter Appium smoke spec Then all three scenarios pass: navigation to tokens full view, network filter in full view, and homepage tokens unaffected by full-view filter ``` Local Android commands: ```bash CI=true ANDROID_DEVICE_UDID=emulator-5554 SKIP_DEVICE_BOOT=true \ yarn appium-smoke:android -- tests/smoke-appium/networks/network-manager2.spec.ts CI=true ANDROID_DEVICE_UDID=emulator-5554 SKIP_DEVICE_BOOT=true \ yarn appium-smoke:android -- tests/smoke-appium/networks/homepage-sections-network-filter.spec.ts ``` ## **Screenshots/Recordings** N/A — test infrastructure and E2E migration only; no UI/product changes. ### **Before** N/A ### **After** N/A ## **Pre-merge author checklist** - [x] I've followed [MetaMask Contributor Docs](https://github.com/MetaMask/contributor-docs) and [MetaMask Mobile Coding Standards](https://github.com/MetaMask/metamask-mobile/blob/main/.github/guidelines/CODING_GUIDELINES.md). - [x] I've completed the PR template to the best of my ability - [x] I've included tests if applicable - [x] I've documented my code using [JSDoc](https://jsdoc.app/) format if applicable - [ ] I've applied the right labels on the PR (see [labeling guidelines](https://github.com/MetaMask/metamask-mobile/blob/main/.github/guidelines/LABELING_GUIDELINES.md)). Not required for external contributors. #### Performance checks (if applicable) N/A — E2E test migration only. ## **Pre-merge reviewer checklist** - [ ] I've manually tested the PR (e.g. pull and build branch, run the app, test code being changed). - [ ] I confirm that this PR addresses all acceptance criteria described in the ticket it closes and includes the necessary testing evidence such as recordings and or screenshots. <!-- CURSOR_SUMMARY --> --- > [!NOTE] > **Low Risk** > Test and CI workflow changes only; no production app logic. Shared page objects and framework code affect both Detox and Appium paths, so regressions would surface in E2E rather than user-facing behavior. > > **Overview** > Moves **`tests/smoke/networks/`** network smoke coverage to **`tests/smoke-appium/networks/`** and deletes the Detox specs once Appium parity exists. **CI** gains Android/iOS Appium jobs for **`SmokeNetworkAbstractions`** and **`SmokeWalletPlatform`**. > > Shared E2E helpers are extended for Appium browser and wallet flows: HTTP URL-bar navigation vs **`dapp://`** deeplinks, tab-grid and URL-bar visibility, native connect/label assertions, webview selection for localhost dapps, fixture-wide dapp port rewriting, and wallet/network-manager navigation (tokens section scroll, network manager readiness). > > Specs switch to Playwright fixtures and **`loginToAppPlaywright`**; default API mocks add an empty notifications POST. Detox **`navigateToURL`** still uses replace + submit with optional URL-editor dismiss; Appium test-dapp load checks the native URL bar instead of webview fox logo. > > <sup>Reviewed by [Cursor Bugbot](https://cursor.com/bugbot) for commit d2a9bb5. Bugbot is set up for automated code reviews on this repo. Configure [here](https://www.cursor.com/dashboard/bugbot).</sup> <!-- /CURSOR_SUMMARY --> --------- Co-authored-by: Cursor Agent <cursoragent@cursor.com> Co-authored-by: javiergarciavera <javiergarciavera@users.noreply.github.com> Co-authored-by: Claude Sonnet 4.6 <noreply@anthropic.com>
1 parent c21e66e commit 78a2d32

20 files changed

Lines changed: 904 additions & 438 deletions

.github/workflows/run-appium-smoke-tests-android.yml

Lines changed: 46 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -149,3 +149,49 @@ jobs:
149149
metamask_environment: ${{ inputs.metamask_environment }}
150150
runner_provider: ${{ inputs.runner_provider }}
151151
secrets: inherit
152+
153+
appium-network-abstractions-android-smoke:
154+
if: >-
155+
${{
156+
!cancelled() &&
157+
(contains(fromJson(inputs.selected_tags), 'ALL') ||
158+
contains(fromJson(inputs.selected_tags), 'SmokeNetworkAbstractions'))
159+
}}
160+
strategy:
161+
matrix:
162+
split: [1]
163+
fail-fast: false
164+
uses: ./.github/workflows/run-appium-e2e-workflow.yml
165+
with:
166+
test-suite-name: appium-network-abstractions-android-smoke
167+
platform: android
168+
test_suite_tag: SmokeNetworkAbstractions
169+
split_number: ${{ matrix.split }}
170+
total_splits: 1
171+
build_type: ${{ inputs.build_type }}
172+
metamask_environment: ${{ inputs.metamask_environment }}
173+
runner_provider: ${{ inputs.runner_provider }}
174+
secrets: inherit
175+
176+
appium-wallet-platform-android-smoke:
177+
if: >-
178+
${{
179+
!cancelled() &&
180+
(contains(fromJson(inputs.selected_tags), 'ALL') ||
181+
contains(fromJson(inputs.selected_tags), 'SmokeWalletPlatform'))
182+
}}
183+
strategy:
184+
matrix:
185+
split: [1]
186+
fail-fast: false
187+
uses: ./.github/workflows/run-appium-e2e-workflow.yml
188+
with:
189+
test-suite-name: appium-wallet-platform-android-smoke
190+
platform: android
191+
test_suite_tag: SmokeWalletPlatform
192+
split_number: ${{ matrix.split }}
193+
total_splits: 1
194+
build_type: ${{ inputs.build_type }}
195+
metamask_environment: ${{ inputs.metamask_environment }}
196+
runner_provider: ${{ inputs.runner_provider }}
197+
secrets: inherit

.github/workflows/run-appium-smoke-tests-ios.yml

Lines changed: 46 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -128,3 +128,49 @@ jobs:
128128
metamask_environment: ${{ inputs.metamask_environment || 'e2e' }}
129129
runner_provider: ${{ inputs.runner_provider || 'current' }}
130130
secrets: inherit
131+
132+
appium-network-abstractions-ios-smoke:
133+
if: >-
134+
${{
135+
!cancelled() &&
136+
(contains(fromJson(inputs.selected_tags || '["ALL"]'), 'ALL') ||
137+
contains(fromJson(inputs.selected_tags || '["ALL"]'), 'SmokeNetworkAbstractions'))
138+
}}
139+
strategy:
140+
matrix:
141+
split: [1]
142+
fail-fast: false
143+
uses: ./.github/workflows/run-appium-e2e-workflow.yml
144+
with:
145+
test-suite-name: appium-network-abstractions-ios-smoke
146+
platform: ios
147+
test_suite_tag: SmokeNetworkAbstractions
148+
split_number: ${{ matrix.split }}
149+
total_splits: 1
150+
build_type: ${{ inputs.build_type || 'main' }}
151+
metamask_environment: ${{ inputs.metamask_environment || 'e2e' }}
152+
runner_provider: ${{ inputs.runner_provider || 'current' }}
153+
secrets: inherit
154+
155+
appium-wallet-platform-ios-smoke:
156+
if: >-
157+
${{
158+
!cancelled() &&
159+
(contains(fromJson(inputs.selected_tags || '["ALL"]'), 'ALL') ||
160+
contains(fromJson(inputs.selected_tags || '["ALL"]'), 'SmokeWalletPlatform'))
161+
}}
162+
strategy:
163+
matrix:
164+
split: [1]
165+
fail-fast: false
166+
uses: ./.github/workflows/run-appium-e2e-workflow.yml
167+
with:
168+
test-suite-name: appium-wallet-platform-ios-smoke
169+
platform: ios
170+
test_suite_tag: SmokeWalletPlatform
171+
split_number: ${{ matrix.split }}
172+
total_splits: 1
173+
build_type: ${{ inputs.build_type || 'main' }}
174+
metamask_environment: ${{ inputs.metamask_environment || 'e2e' }}
175+
runner_provider: ${{ inputs.runner_provider || 'current' }}
176+
secrets: inherit

tests/api-mocking/mock-responses/defaults/index.ts

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -213,6 +213,12 @@ export const DEFAULT_MOCKS = {
213213
},
214214
...(DEFAULT_REWARDS_MOCKS.POST || []),
215215
...(PERPS_HYPERLIQUID_MOCKS.POST || []),
216+
{
217+
urlEndpoint:
218+
/^https:\/\/notification\.api\.cx\.metamask\.io\/api\/v4\/notifications$/,
219+
responseCode: 200,
220+
response: [],
221+
},
216222
],
217223
PUT: [
218224
...(USER_STORAGE_MOCK.PUT || []),

tests/flows/browser.flow.ts

Lines changed: 67 additions & 20 deletions
Original file line numberDiff line numberDiff line change
@@ -5,9 +5,16 @@ import Utilities from '../framework/Utilities';
55
import BrowserView from '../page-objects/Browser/BrowserView';
66
import TestDApp from '../page-objects/Browser/TestDApp';
77
import { BrowserViewSelectorsIDs } from '../../app/components/Views/BrowserTab/BrowserView.testIds';
8+
import { BrowserURLBarSelectorsIDs } from '../../app/components/UI/BrowserUrlBar/BrowserURLBar.testIds';
89
import TabBarComponent from '../page-objects/wallet/TabBarComponent';
910
import TrendingView from '../page-objects/Trending/TrendingView';
11+
import {
12+
encapsulated,
13+
type EncapsulatedElementType,
14+
} from '../framework/EncapsulatedElement';
15+
import PlaywrightMatchers from '../framework/PlaywrightMatchers';
1016
import { FrameworkDetector } from '../framework/FrameworkDetector';
17+
import { getDappUrl } from '../framework/fixtures/FixtureUtils';
1118

1219
/**
1320
* Waits for the test dapp to load.
@@ -17,6 +24,14 @@ import { FrameworkDetector } from '../framework/FrameworkDetector';
1724
* @throws {Error} Throws an error if the test dapp fails to load after a certain number of attempts.
1825
*/
1926
export const waitForTestDappToLoad = async (): Promise<void> => {
27+
if (FrameworkDetector.isAppium()) {
28+
await Assertions.expectElementToBeVisible(
29+
PlaywrightMatchers.getElementByText(getDappUrl(0)),
30+
{ description: 'Browser URL bar should show test dapp URL' },
31+
);
32+
return;
33+
}
34+
2035
const MAX_RETRIES = 3;
2136

2237
for (let attempt = 1; attempt <= MAX_RETRIES; attempt++) {
@@ -27,9 +42,6 @@ export const waitForTestDappToLoad = async (): Promise<void> => {
2742
await Assertions.expectElementToBeVisible(TestDApp.testDappPageTitle, {
2843
description: 'Test Dapp Page Title should be visible',
2944
});
30-
await Assertions.expectElementToBeVisible(TestDApp.DappConnectButton, {
31-
description: 'Test Dapp Connect Button should be visible',
32-
});
3345
return; // Success - page is fully loaded and interactive
3446
} catch (error) {
3547
if (attempt === MAX_RETRIES) {
@@ -97,6 +109,30 @@ export const waitForTestSnapsToLoad = async (): Promise<void> => {
97109
* If the "Opened tabs" grid view is shown (e.g. after tapping the browser tab icon),
98110
* selects the first/most recent tab so we land on the single-tab browser view.
99111
*/
112+
const getFirstBrowserTabInGrid = (): EncapsulatedElementType => {
113+
if (!FrameworkDetector.isAppium()) {
114+
return Matchers.getElementByID(BrowserViewSelectorsIDs.TABS_ITEM_REGEX, 0);
115+
}
116+
117+
return encapsulated({
118+
detox: () =>
119+
Matchers.getElementByID(BrowserViewSelectorsIDs.TABS_ITEM_REGEX, 0),
120+
appium: {
121+
// TabThumbnail sets accessibilityLabel to "Switch tab"; Android exposes it as content-desc.
122+
android: () =>
123+
PlaywrightMatchers.getElementByAndroidUIAutomator(
124+
'.descriptionContains("Switch tab")',
125+
{ index: 0 },
126+
),
127+
ios: () =>
128+
PlaywrightMatchers.getElementById(
129+
BrowserViewSelectorsIDs.TABS_ITEM_REGEX,
130+
{ index: 0 },
131+
),
132+
},
133+
});
134+
};
135+
100136
const ensureSingleBrowserTabView = async (): Promise<void> => {
101137
const openedTabsHeader = Matchers.getElementByID(
102138
BrowserViewSelectorsIDs.TABS_OPENED_TITLE,
@@ -106,32 +142,43 @@ const ensureSingleBrowserTabView = async (): Promise<void> => {
106142
2000,
107143
);
108144
if (isInTabListView) {
109-
const firstTab = Matchers.getElementByID(
110-
BrowserViewSelectorsIDs.TABS_ITEM_REGEX,
111-
0,
112-
);
113-
const hasTabThumbnail = await Utilities.isElementVisible(firstTab, 5000);
114-
if (hasTabThumbnail) {
115-
await Gestures.waitAndTap(firstTab, {
116-
elemDescription: 'First browser tab (select to open single-tab view)',
117-
});
118-
}
145+
const firstTab = getFirstBrowserTabInGrid();
146+
await Gestures.waitAndTap(firstTab, {
147+
elemDescription: 'First browser tab (select to open single-tab view)',
148+
});
119149
}
120150
};
121151

152+
const getBrowserUrlBarVisibleIndicator = (): EncapsulatedElementType =>
153+
encapsulated({
154+
detox: () => Matchers.getElementByID(BrowserURLBarSelectorsIDs.URL_INPUT),
155+
appium: () =>
156+
// TextInput (`browser-modal-url-input`) is hidden when the URL bar is unfocused;
157+
// the wrapper view (`url-input`) stays visible and shows the current URL.
158+
PlaywrightMatchers.getElementById(BrowserViewSelectorsIDs.URL_INPUT),
159+
});
160+
122161
export const navigateToBrowserView = async (): Promise<void> => {
123162
await TabBarComponent.tapExploreButton();
124163
await TrendingView.tapBrowserButton();
125164

126165
// If we landed on the "Opened tabs" grid (tab list), select the first tab to get to single-tab view
127166
await ensureSingleBrowserTabView();
128167

129-
const urlBar = FrameworkDetector.isAppium()
130-
? BrowserView.addressBar
131-
: BrowserView.urlInputBoxID;
168+
await Assertions.expectElementToBeVisible(
169+
getBrowserUrlBarVisibleIndicator(),
170+
{
171+
description: 'Browser URL bar should be visible after navigation',
172+
timeout: FrameworkDetector.isAppium() ? 30_000 : undefined,
173+
},
174+
);
175+
};
132176

133-
await Assertions.expectElementToBeVisible(urlBar, {
134-
description: 'Browser URL bar should be visible after navigation',
135-
timeout: FrameworkDetector.isAppium() ? 30_000 : undefined,
136-
});
177+
export const openUrlInBrowserView = async (): Promise<void> => {
178+
await Gestures.waitAndTap(
179+
PlaywrightMatchers.getElementById(BrowserViewSelectorsIDs.URL_INPUT),
180+
{
181+
elemDescription: 'URL input box',
182+
},
183+
);
137184
};

tests/framework/Assertions.ts

Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -10,6 +10,7 @@ import {
1010
import { Json } from '@metamask/utils';
1111
import { FrameworkDetector } from './FrameworkDetector.ts';
1212
import PlaywrightAssertions from './PlaywrightAssertions.ts';
13+
import PlaywrightContextHelpers from './PlaywrightContextHelpers.ts';
1314

1415
/**
1516
* Assertions with auto-retry and better error messages
@@ -28,6 +29,7 @@ export default class Assertions {
2829
options: AssertionOptions = {},
2930
): Promise<void> {
3031
if (FrameworkDetector.isAppium()) {
32+
await PlaywrightContextHelpers.switchToNativeContext();
3133
return PlaywrightAssertions.expectElementToBeVisible(
3234
asPlaywrightElement(elem as EncapsulatedElementType),
3335
options,
@@ -206,6 +208,15 @@ export default class Assertions {
206208
label: string,
207209
options: AssertionOptions = {},
208210
): Promise<void> {
211+
if (FrameworkDetector.isAppium()) {
212+
await PlaywrightContextHelpers.switchToNativeContext();
213+
return PlaywrightAssertions.expectElementToHaveLabel(
214+
asPlaywrightElement(elem),
215+
label,
216+
options,
217+
);
218+
}
219+
209220
const {
210221
timeout = BASE_DEFAULTS.timeout,
211222
description = `element has label "${label}"`,

tests/framework/PlaywrightAssertions.ts

Lines changed: 63 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -3,6 +3,7 @@ import { AssertionOptions } from './types.ts';
33
import type { PlaywrightElement } from './PlaywrightAdapter.ts';
44
import PlaywrightMatchers from './PlaywrightMatchers.ts';
55
import PlaywrightGestures from './PlaywrightGestures.ts';
6+
import { PlatformDetector } from './PlatformLocator.ts';
67
import {
78
addOverhead,
89
isOverheadTrackingActive,
@@ -298,6 +299,68 @@ export default class PlaywrightAssertions {
298299
);
299300
}
300301

302+
static async expectElementToHaveLabel(
303+
targetElement: PlaywrightElement | Promise<PlaywrightElement>,
304+
expectedLabel: string,
305+
options: AssertionOptions = {},
306+
): Promise<void> {
307+
const timeout = this.getTimeout(options);
308+
const interval = 300;
309+
const start = Date.now();
310+
const el = await targetElement;
311+
312+
const normalize = (value: string): string =>
313+
value
314+
.replace(/[\s,]+/g, ' ')
315+
.trim()
316+
.toLowerCase();
317+
318+
const matchesLabel = (actual: string): boolean => {
319+
const normalizedActual = normalize(actual);
320+
const normalizedExpected = normalize(expectedLabel);
321+
if (normalizedActual.includes(normalizedExpected)) {
322+
return true;
323+
}
324+
325+
return expectedLabel
326+
.split(',')
327+
.map((part) => normalize(part))
328+
.every((part) => part.length > 0 && normalizedActual.includes(part));
329+
};
330+
331+
while (Date.now() - start < timeout) {
332+
const label = await this.getElementAccessibilityLabel(el);
333+
if (matchesLabel(label)) {
334+
return;
335+
}
336+
await sleep(interval);
337+
}
338+
339+
const lastLabel = await this.getElementAccessibilityLabel(el);
340+
throw new Error(
341+
`Expected label "${expectedLabel}" but got "${lastLabel}" within ${timeout}ms`,
342+
);
343+
}
344+
345+
private static async getElementAccessibilityLabel(
346+
el: PlaywrightElement,
347+
): Promise<string> {
348+
const raw = el.unwrap();
349+
const isAndroid = await PlatformDetector.isAndroid();
350+
const accessibilityText = isAndroid
351+
? ((await raw.getAttribute('content-desc')) ?? '')
352+
: ((await raw.getAttribute('label')) ??
353+
(await raw.getAttribute('name')) ??
354+
'');
355+
let text = '';
356+
try {
357+
text = await raw.getText();
358+
} catch {
359+
text = '';
360+
}
361+
return [accessibilityText, text].filter(Boolean).join(', ');
362+
}
363+
301364
static async expectTextNotDisplayed(
302365
text: string,
303366
options: AssertionOptions = {},

tests/framework/PlaywrightContextHelpers.ts

Lines changed: 10 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -104,17 +104,23 @@ export default class PlaywrightContextHelpers {
104104
webviews: DetailedContext[],
105105
dappUrl?: string,
106106
): Promise<DetailedContext | undefined> {
107+
const targetsLocalhost = Boolean(dappUrl?.includes('localhost'));
108+
107109
if (dappUrl) {
108-
const urlMatch = webviews.find(
109-
(ctx) => ctx.url?.includes(dappUrl) && !/localhost/i.test(ctx.url),
110-
);
110+
const urlMatch = webviews.find((ctx) => {
111+
if (!ctx.url?.includes(dappUrl)) {
112+
return false;
113+
}
114+
return targetsLocalhost || !/localhost/i.test(ctx.url ?? '');
115+
});
111116
if (urlMatch) return urlMatch;
112117
}
113118

114119
const filtered = webviews.filter((ctx) => {
115120
const shouldAvoid =
116121
/devtools/i.test(ctx.id) ||
117-
(ctx.url && /chrome|devtools|localhost/i.test(ctx.url));
122+
(ctx.url && /chrome|devtools/i.test(ctx.url)) ||
123+
(!targetsLocalhost && ctx.url && /localhost/i.test(ctx.url));
118124
return !shouldAvoid;
119125
});
120126

0 commit comments

Comments
 (0)