@deeeed/metamask-harness 0.39.0 → 0.40.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (67) hide show
  1. package/CHANGELOG.md +46 -0
  2. package/adapters/extension/lib/playwright-cdp.cjs +17 -1
  3. package/adapters/extension/sidepanel-toggle.sh +135 -13
  4. package/dist/adapters/mobile/release-artifact.js +65 -0
  5. package/dist/adapters.js +8 -7
  6. package/dist/command-contract.js +10 -2
  7. package/dist/commands/call.js +7 -4
  8. package/dist/commands/launch/index.js +55 -3
  9. package/dist/commands/parse-args.js +6 -0
  10. package/dist/commands/run-engine.js +75 -39
  11. package/dist/funding-execution-context.js +1386 -0
  12. package/dist/live-adapter-contract.js +3 -1
  13. package/dist/metamask-action-validation.js +170 -3
  14. package/dist/mm-harness-cli.js +8 -2
  15. package/dist/recipe-security.js +5 -12
  16. package/dist/runner.js +102 -6
  17. package/docs/RECIPES.md +74 -1
  18. package/docs/RELEASE-QA-CAPABILITY-MAP.md +6 -5
  19. package/library/actions/extension/networks/add_chainlist.mjs +4 -0
  20. package/library/actions/extension/networks/add_custom.mjs +4 -0
  21. package/library/actions/extension/networks/custom_network.mjs +48 -1
  22. package/library/actions/extension/perps/assert_visible_consistency.mjs +366 -0
  23. package/library/actions/extension/perps/close_visible_position.mjs +299 -37
  24. package/library/actions/extension/perps/compare_provider_market.mjs +77 -0
  25. package/library/actions/extension/perps/edit_margin.mjs +285 -44
  26. package/library/actions/extension/perps/mutation-receipt.mjs +781 -0
  27. package/library/actions/extension/perps/perps.mjs +1029 -363
  28. package/library/actions/extension/perps/read_funds_confirmation.mjs +126 -13
  29. package/library/actions/extension/perps/read_snapshot.mjs +124 -3
  30. package/library/actions/extension/perps/read_visible_state.mjs +63 -5
  31. package/library/actions/extension/perps/select_activity_filter.mjs +81 -18
  32. package/library/actions/extension/perps/select_market_filter.mjs +48 -4
  33. package/library/actions/extension/perps/update_position_tpsl.mjs +251 -69
  34. package/library/actions/extension/perps/visible-mutation-identity.mjs +164 -0
  35. package/library/actions/extension/platform/cdp.mjs +88 -9
  36. package/library/actions/extension/settings/set_basic_functionality.mjs +35 -7
  37. package/library/actions/extension/ui/navigate.mjs +45 -5
  38. package/library/actions/extension/wallet/import.mjs +70 -29
  39. package/library/actions/extension/wallet/lock.mjs +62 -5
  40. package/library/actions/extension/wallet/visible-session.mjs +218 -0
  41. package/library/actions/mobile/ui/native-navigation.mjs +45 -0
  42. package/library/actions/mobile/wallet/import.mjs +17 -10
  43. package/library/actions/mobile/wallet/native-ui.mjs +21 -1
  44. package/library/actions/shared/perps/visible-state.mjs +25 -5
  45. package/library/manifests/extension.action-manifest.json +1696 -1271
  46. package/library/recipes/extension/assets/release-custom-network-token-persistence.recipe.json +1 -0
  47. package/library/recipes/{perps → extension/perps}/release-activity-filters.recipe.json +5 -5
  48. package/library/recipes/{perps → extension/perps}/release-funds-flows.recipe.json +3 -3
  49. package/library/recipes/extension/perps/release-live-limit-order.recipe.json +24 -19
  50. package/library/recipes/extension/perps/release-market-filters.recipe.json +97 -0
  51. package/library/recipes/extension/perps/release-order-entry.recipe.json +4 -1
  52. package/library/recipes/extension/perps/release-order-validation.recipe.json +53 -14
  53. package/library/recipes/extension/perps/release-visible-consistency.recipe.json +47 -0
  54. package/library/recipes/extension/perps/source-dev-snapshot-consistency.recipe.json +76 -0
  55. package/library/recipes/perps/release-live-market-order.recipe.json +30 -13
  56. package/library/recipes/perps/release-market-details.recipe.json +1 -1
  57. package/library/recipes/swap-bridge/release-quote.recipe.json +1 -0
  58. package/library/recipes/wallet/import.recipe.json +4 -14
  59. package/package.json +1 -1
  60. package/library/actions/extension/assets/finish_send.mjs +0 -128
  61. package/library/actions/extension/swap_bridge/submit_transaction.mjs +0 -363
  62. package/library/actions/extension/ui/activate_and_observe.mjs +0 -96
  63. package/library/recipes/assets/release-custom-gas-send.recipe.json +0 -88
  64. package/library/recipes/assets/release-native-max-cancel.recipe.json +0 -70
  65. package/library/recipes/assets/release-native-max-send.recipe.json +0 -78
  66. package/library/recipes/assets/release-own-account-send.recipe.json +0 -80
  67. package/library/recipes/extension/runner/action-validation.recipe.json +0 -343
@@ -0,0 +1,218 @@
1
+ import { createHash } from 'node:crypto';
2
+
3
+ const ACCOUNT_SEARCH = 'input[data-testid="multichain-account-list-search"], [data-testid="multichain-account-list-search"] input';
4
+ const ONBOARDING_COMPLETE = '[data-testid="onboarding-complete-done"]';
5
+ const HOME_BOUNDARY = '[data-testid="bottom-nav-home"], [data-testid="account-overview__asset-tab"]';
6
+
7
+ async function hasVisibleSelector(page, selector, deadline) {
8
+ const expression = `(() => {
9
+ const element = document.querySelector(${JSON.stringify(selector)});
10
+ if (!element) return false;
11
+ const style = getComputedStyle(element);
12
+ const rect = element.getBoundingClientRect();
13
+ return style.display !== 'none' && style.visibility !== 'hidden' && rect.width > 0 && rect.height > 0;
14
+ })()`;
15
+ if (deadline === undefined) return page.evaluate(expression);
16
+ const result = await page.session.call('Runtime.evaluate', {
17
+ expression,
18
+ awaitPromise: true,
19
+ returnByValue: true,
20
+ }, { timeoutMs: Math.min(1000, Math.max(1, deadline - Date.now())) });
21
+ if (result.exceptionDetails) throw new Error('Visible wallet selector probe failed.');
22
+ return result.result?.value === true;
23
+ }
24
+
25
+ export async function dismissVisibleOnboardingCompletion(page) {
26
+ if (!(await hasVisibleSelector(page, ONBOARDING_COMPLETE))) return false;
27
+ await page.click(ONBOARDING_COMPLETE);
28
+ return true;
29
+ }
30
+
31
+ function accountProbeAddress(expectedAddress) {
32
+ const replacement = expectedAddress.endsWith('0') ? '1' : '0';
33
+ return `${expectedAddress.slice(0, -1)}${replacement}`;
34
+ }
35
+
36
+ async function currentRoute(page, deadline) {
37
+ const history = await page.session.call('Page.getNavigationHistory', {}, {
38
+ timeoutMs: Math.min(1000, Math.max(1, deadline - Date.now())),
39
+ });
40
+ const url = String(history.entries?.[history.currentIndex]?.url ?? '');
41
+ const route = new URL(url).hash.split('?')[0];
42
+ return route.replace(/0x[0-9a-f]{40}/giu, '<redacted-address>').slice(0, 200);
43
+ }
44
+
45
+ async function routeError(page, message, deadline) {
46
+ let route = '<deadline-expired>';
47
+ if (Date.now() < deadline) {
48
+ try {
49
+ route = await currentRoute(page, deadline);
50
+ } catch {
51
+ route = '<unavailable>';
52
+ }
53
+ }
54
+ return new Error(`${message} (route ${JSON.stringify(route)}).`);
55
+ }
56
+
57
+ async function visibleAccountMatch(page, deadline) {
58
+ const result = await page.session.call('Runtime.evaluate', {
59
+ expression: `(() => {
60
+ const input = document.querySelector(${JSON.stringify(ACCOUNT_SEARCH)});
61
+ const query = String(input?.value || '').trim().toLowerCase();
62
+ const rows = [...document.querySelectorAll('.multichain-account-cell[data-testid^="multichain-account-cell-"]')]
63
+ .filter((element) => {
64
+ const style = getComputedStyle(element);
65
+ const rect = element.getBoundingClientRect();
66
+ return style.display !== 'none' && style.visibility !== 'hidden' && rect.width > 0 && rect.height > 0;
67
+ });
68
+ const row = rows.length === 1 ? rows[0] : null;
69
+ const accountName = String(row?.querySelector('.multichain-account-cell__account-name')?.textContent || '')
70
+ .trim()
71
+ .toLowerCase();
72
+ return {
73
+ query,
74
+ count: rows.length,
75
+ testId: row?.getAttribute('data-testid') || null,
76
+ selected: Boolean(row?.classList.contains('is-selected')),
77
+ addressMatchedBySearch: Boolean(/^0x[0-9a-f]{40}$/u.test(query) && !accountName.includes(query)),
78
+ };
79
+ })()`,
80
+ awaitPromise: true,
81
+ returnByValue: true,
82
+ }, { timeoutMs: Math.min(1000, Math.max(1, deadline - Date.now())) });
83
+ if (result.exceptionDetails || !result.result?.value) {
84
+ throw new Error('Visible wallet account probe failed.');
85
+ }
86
+ return result.result.value;
87
+ }
88
+
89
+ async function navigateHashThroughPage(page, hash, deadline) {
90
+ const pathname = new URL(String(page.target?.url ?? `${page.origin}/home.html`)).pathname;
91
+ const walletPath = pathname === '/sidepanel.html' ? pathname : '/home.html';
92
+ await page.session.call('Page.navigate', { url: `${page.origin}${walletPath}${hash}` }, {
93
+ timeoutMs: Math.min(1000, Math.max(1, deadline - Date.now())),
94
+ });
95
+ }
96
+
97
+ async function withinDeadline(promise, deadline) {
98
+ const timeoutMs = Math.max(1, deadline - Date.now());
99
+ let timer;
100
+ try {
101
+ return await Promise.race([
102
+ promise,
103
+ new Promise((_, reject) => {
104
+ timer = setTimeout(() => reject(new Error('Visible wallet stage exceeded its shared deadline.')), timeoutMs);
105
+ }),
106
+ ]);
107
+ } finally {
108
+ clearTimeout(timer);
109
+ }
110
+ }
111
+
112
+ async function leavePerpsForWalletHome(page, deadline) {
113
+ if (!(await currentRoute(page, deadline)).includes('/perps')) return;
114
+ while (Date.now() <= deadline) {
115
+ try {
116
+ await navigateHashThroughPage(page, '#/', deadline);
117
+ if (await hasVisibleSelector(page, HOME_BOUNDARY, deadline)) return;
118
+ } catch {
119
+ // Retry within the shared deadline while the authenticated Home surface settles.
120
+ }
121
+ await new Promise((resolve) => setTimeout(resolve, 100));
122
+ }
123
+ throw await routeError(page, 'Visible wallet session did not reach Home before account-list navigation', deadline);
124
+ }
125
+
126
+ async function openAccountList(page, deadline) {
127
+ await leavePerpsForWalletHome(page, deadline);
128
+ while (Date.now() <= deadline) {
129
+ try {
130
+ await navigateHashThroughPage(page, '#/account-list', deadline);
131
+ if (await hasVisibleSelector(page, ACCOUNT_SEARCH, deadline)) return;
132
+ } catch {
133
+ await new Promise((resolve) => setTimeout(resolve, 100));
134
+ }
135
+ }
136
+ throw await routeError(page, 'Visible wallet session did not reach the authenticated account list', deadline);
137
+ }
138
+
139
+ async function proveAddressSearchControl(page, expectedAddress, deadline) {
140
+ const probeAddress = accountProbeAddress(expectedAddress);
141
+ try {
142
+ await withinDeadline(page.setInput(ACCOUNT_SEARCH, probeAddress), deadline);
143
+ } catch {
144
+ throw await routeError(page, 'Visible wallet account search could not enter the alternate-address control', deadline);
145
+ }
146
+ let consecutiveEmpty = 0;
147
+ while (Date.now() <= deadline) {
148
+ let observed;
149
+ try {
150
+ observed = await visibleAccountMatch(page, deadline);
151
+ } catch {
152
+ throw await routeError(page, 'Visible wallet alternate-address result did not become readable', deadline);
153
+ }
154
+ if (observed.query === probeAddress && observed.count === 0) {
155
+ consecutiveEmpty += 1;
156
+ if (consecutiveEmpty === 2) return;
157
+ } else {
158
+ consecutiveEmpty = 0;
159
+ }
160
+ await new Promise((resolve) => setTimeout(resolve, 100));
161
+ }
162
+ throw await routeError(page, 'Visible wallet account search did not reject the alternate address control', deadline);
163
+ }
164
+
165
+ async function queryExpectedAccount(page, expectedAddress, deadline, requireSelected) {
166
+ await proveAddressSearchControl(page, expectedAddress, deadline);
167
+ try {
168
+ await withinDeadline(page.setInput(ACCOUNT_SEARCH, expectedAddress), deadline);
169
+ } catch {
170
+ throw await routeError(page, 'Visible wallet account search could not enter the derived release identity', deadline);
171
+ }
172
+ let observed;
173
+ while (Date.now() <= deadline) {
174
+ try {
175
+ observed = await visibleAccountMatch(page, deadline);
176
+ } catch {
177
+ throw await routeError(page, 'Visible wallet derived-account result did not become readable', deadline);
178
+ }
179
+ const exactMatch = observed.query === expectedAddress &&
180
+ observed.count === 1 &&
181
+ observed.testId &&
182
+ observed.addressMatchedBySearch;
183
+ if (exactMatch && (!requireSelected || observed.selected)) return observed;
184
+ await new Promise((resolve) => setTimeout(resolve, 100));
185
+ }
186
+ if (requireSelected) {
187
+ throw await routeError(page, 'Visible wallet session did not converge to the derived release account', deadline);
188
+ }
189
+ throw await routeError(page, 'Visible wallet session did not resolve the derived release account to one rendered account row', deadline);
190
+ }
191
+
192
+ export async function ensureVisibleSelectedAccount(page, expectedAddress, timeoutMs) {
193
+ if (!/^0x[0-9a-f]{40}$/u.test(expectedAddress)) {
194
+ throw new Error('Visible wallet session requires a derived EVM account address.');
195
+ }
196
+ const deadline = Date.now() + timeoutMs;
197
+ await openAccountList(page, deadline);
198
+ const initial = await queryExpectedAccount(page, expectedAddress, deadline, false);
199
+ let selectionPerformed = false;
200
+ if (!initial.selected) {
201
+ selectionPerformed = true;
202
+ try {
203
+ await withinDeadline(page.click(`[data-testid=${JSON.stringify(initial.testId)}]`), deadline);
204
+ } catch {
205
+ throw await routeError(page, 'Visible wallet session could not select the derived release account', deadline);
206
+ }
207
+ }
208
+
209
+ await openAccountList(page, deadline);
210
+ await queryExpectedAccount(page, expectedAddress, deadline, true);
211
+ return {
212
+ selectedIdentitySha256: createHash('sha256').update(expectedAddress).digest('hex'),
213
+ selectedAddressMatched: true,
214
+ selectionPerformed,
215
+ accountSurface: 'visible-account-list',
216
+ redacted: true,
217
+ };
218
+ }
@@ -22,6 +22,8 @@ const IDS = {
22
22
  tutorialSkip: 'perps-tutorial-skip-button',
23
23
  modeLite: 'perps-mode-selection-lite-option',
24
24
  modePro: 'perps-mode-selection-pro-option',
25
+ modeToggleLite: 'perps-mode-toggle-lite',
26
+ modeTogglePro: 'perps-mode-toggle-pro',
25
27
  market: 'perps-market-details-view',
26
28
  marketBack: 'perps-market-header-back-button',
27
29
  marketSubtitle: 'perps-market-header-subtitle',
@@ -109,6 +111,8 @@ export async function navigateOpaqueMobile(input, destination) {
109
111
  observedScreen = 'wallet-home';
110
112
  } else if (destination.page === 'swap') {
111
113
  observedScreen = await navigateSwap({ identifiers, press, wait, back });
114
+ } else if (destination.page === 'perps') {
115
+ observedScreen = await navigatePerpsHome({ identifiers, press, wait, back, timeoutMs: Number(input.node?.timeout_ms ?? 30_000), perpsMode });
112
116
  } else if (destination.page === 'perps-market-list') {
113
117
  observedScreen = await navigatePerpsMarketList({ identifiers, press, wait, back, timeoutMs: Number(input.node?.timeout_ms ?? 30_000), perpsMode });
114
118
  } else {
@@ -265,6 +269,47 @@ async function navigatePerps({ identifiers, press, wait, back, timeoutMs, perpsM
265
269
  return waitForPerpsDestination(identifiers, timeoutMs, press, perpsMode);
266
270
  }
267
271
 
272
+ async function navigatePerpsHome({ identifiers, press, wait, back, timeoutMs, perpsMode }) {
273
+ const deadline = Date.now() + timeoutMs;
274
+ let observed = await navigatePerps({ identifiers, press, wait, back, timeoutMs, perpsMode });
275
+ while (Date.now() <= deadline) {
276
+ let ids = await identifiers();
277
+ if (ids.has(IDS.perpsHome)) return IDS.perpsHome;
278
+ if (perpsMode === 'lite' && ids.has(IDS.proMarket)) {
279
+ if (!ids.has(IDS.modeTogglePro)) {
280
+ throw new Error('Opaque Mobile navigation reached Pro market details without the visible Pro mode control.');
281
+ }
282
+ await press(IDS.modeTogglePro);
283
+ observed = await waitForPerpsMode(identifiers, press, deadline, 'lite');
284
+ ids = await identifiers();
285
+ }
286
+ if (ids.has(IDS.market)) {
287
+ await press(IDS.marketBack);
288
+ } else if (ids.has(IDS.marketList)) {
289
+ await press(IDS.marketListClose);
290
+ } else {
291
+ observed = await navigatePerps({ identifiers, press, wait, back, timeoutMs: Math.max(1, deadline - Date.now()), perpsMode });
292
+ }
293
+ }
294
+ throw new Error(`Opaque Mobile navigation reached ${observed}, but did not converge to Perps Home.`);
295
+ }
296
+
297
+ async function waitForPerpsMode(identifiers, press, deadline, perpsMode) {
298
+ let selected = false;
299
+ while (Date.now() <= deadline) {
300
+ const ids = await identifiers();
301
+ const expected = perpsMode === 'pro' ? IDS.proMarket : IDS.market;
302
+ if (ids.has(expected)) return expected;
303
+ const option = perpsMode === 'pro' ? IDS.modePro : IDS.modeLite;
304
+ if (!selected && ids.has(option)) {
305
+ await press(option);
306
+ selected = true;
307
+ }
308
+ await new Promise((resolve) => setTimeout(resolve, 250));
309
+ }
310
+ throw new Error(`Opaque Mobile navigation did not visibly switch Perps to ${perpsMode} mode.`);
311
+ }
312
+
268
313
  async function navigatePerpsMarketList({ identifiers, press, wait, back, timeoutMs, perpsMode }) {
269
314
  let ids = await identifiers();
270
315
  if (ids.has(IDS.marketList)) return IDS.marketList;
@@ -1,3 +1,4 @@
1
+ import { createHash } from 'node:crypto';
1
2
  import { mkdtemp, rm, writeFile } from 'node:fs/promises';
2
3
  import os from 'node:os';
3
4
  import path from 'node:path';
@@ -45,6 +46,18 @@ function selectedAddress(status, input) {
45
46
  return String(selectedEntry(status, input)?.account?.address ?? '').toLowerCase();
46
47
  }
47
48
 
49
+ export function redactedSelectedIdentity(expectedAddress, actualAddress) {
50
+ const expected = String(expectedAddress ?? '').toLowerCase();
51
+ const actual = String(actualAddress ?? '').toLowerCase();
52
+ if (!expected || actual !== expected) {
53
+ throw new Error('Mobile wallet import did not visibly select the expected account.');
54
+ }
55
+ return {
56
+ selectedIdentitySha256: createHash('sha256').update(expected).digest('hex'),
57
+ selectedAddressMatched: true,
58
+ };
59
+ }
60
+
48
61
  function walletReady(observed, input) {
49
62
  const route = String(selectedEntry(observed, input)?.route?.name ?? '');
50
63
  return Boolean(
@@ -199,9 +212,7 @@ async function importThroughUi(input, timeoutMs, options) {
199
212
  observed = await status(input);
200
213
  }
201
214
  const actualAddress = selectedAddress(observed, input);
202
- if (actualAddress !== credentials.expectedAddress) {
203
- throw new Error(`Wallet import selected ${actualAddress || 'no address'}, expected ${credentials.expectedAddress}.`);
204
- }
215
+ redactedSelectedIdentity(credentials.expectedAddress, actualAddress);
205
216
  const { consent } = await setMobileConsent(
206
217
  input,
207
218
  metametricsEnabled,
@@ -255,7 +266,7 @@ runAdapter(async (input) => {
255
266
  method: proof.alreadyReady ? 'native-profile' : 'native-ui-import',
256
267
  credentialSource: credentials.source,
257
268
  credentialSourceName: credentials.sourceName,
258
- selectedAddress: proof.selectedAddress,
269
+ ...redactedSelectedIdentity(credentials.expectedAddress, proof.selectedAddress),
259
270
  metametricsRequested: metametricsEnabled,
260
271
  biometricsRequested: biometricsEnabled,
261
272
  biometricPromptDismissed: proof.biometricPromptDismissed ?? false,
@@ -294,7 +305,7 @@ runAdapter(async (input) => {
294
305
  method: 'ui-import',
295
306
  credentialSource: imported.credentials.source,
296
307
  credentialSourceName: imported.credentials.sourceName,
297
- selectedAddress: importedEntry.account.address,
308
+ ...redactedSelectedIdentity(imported.credentials.expectedAddress, importedEntry.account.address),
298
309
  route: importedEntry.route ?? null,
299
310
  metametricsEnabled: imported.metametricsEnabled,
300
311
  interests: imported.interests,
@@ -311,16 +322,12 @@ runAdapter(async (input) => {
311
322
  ...(route === 'Login' ? { password: credentials.password } : {}),
312
323
  },
313
324
  });
314
- const actualAddress = String(unlocked.account?.address ?? address).toLowerCase();
315
- if (actualAddress !== credentials.expectedAddress) {
316
- throw new Error(`Wallet profile selected ${actualAddress || 'no address'}, expected ${credentials.expectedAddress}.`);
317
- }
318
325
  return {
319
326
  action: input.action,
320
327
  method: 'profile',
321
328
  credentialSource: credentials.source,
322
329
  credentialSourceName: credentials.sourceName,
323
- selectedAddress: unlocked.account?.address ?? address,
330
+ ...redactedSelectedIdentity(credentials.expectedAddress, unlocked.account?.address ?? address),
324
331
  route: unlocked.route ?? entry.route ?? null,
325
332
  redacted: true,
326
333
  proofPath: 'mobile-fixture-profile',
@@ -33,6 +33,8 @@ const TEST_IDS = {
33
33
  perpsHomeBack: 'perps-home-back-button',
34
34
  perpsMarket: 'perps-market-details-view',
35
35
  perpsMarketBack: 'perps-market-header-back-button',
36
+ perpsProMarket: 'perps-pro-market-scroll-view',
37
+ perpsProMarketBack: 'perps-pro-market-header-back-button',
36
38
  hamburger: 'navbar-hamburger-menu-button',
37
39
  lock: 'lock-menu-item',
38
40
  confirmLock: 'android:id/button1',
@@ -343,7 +345,12 @@ function nativeSession(input) {
343
345
  ) {
344
346
  return state;
345
347
  }
346
- if (state.visible.has(TEST_IDS.perpsMarketBack)) {
348
+ if (
349
+ state.visible.has(TEST_IDS.perpsProMarket) &&
350
+ state.visible.has(TEST_IDS.perpsProMarketBack)
351
+ ) {
352
+ await this.press(TEST_IDS.perpsProMarketBack, timeoutMs);
353
+ } else if (state.visible.has(TEST_IDS.perpsMarketBack)) {
347
354
  await this.press(TEST_IDS.perpsMarketBack, timeoutMs);
348
355
  } else if (state.visible.has(TEST_IDS.perpsHomeBack)) {
349
356
  await this.press(TEST_IDS.perpsHomeBack, timeoutMs);
@@ -408,6 +415,13 @@ function nativeSession(input) {
408
415
  if (!walletVisible) stableWalletCaptures = 0;
409
416
  if (visible.has(TEST_IDS.metricsContinue)) {
410
417
  if (!options.metametricsEnabled) {
418
+ if (!visible.has(TEST_IDS.metricsCheckbox)) {
419
+ await this.scroll('down', options.timeoutMs);
420
+ const afterScroll = await this.screenState();
421
+ if (!afterScroll.visible.has(TEST_IDS.metricsCheckbox)) {
422
+ throw new Error('Mobile MetaMetrics opt-out checkbox remained unavailable after one bounded onboarding scroll.');
423
+ }
424
+ }
411
425
  await this.press(TEST_IDS.metricsCheckbox, options.timeoutMs);
412
426
  }
413
427
  await this.press(TEST_IDS.metricsContinue, options.timeoutMs);
@@ -477,6 +491,12 @@ function nativeSession(input) {
477
491
  timeout_ms: timeoutMs,
478
492
  settle: false,
479
493
  }),
494
+ scroll: (direction, timeoutMs) => execute('ui.scroll', {
495
+ direction,
496
+ amount: 0.75,
497
+ timeout_ms: timeoutMs,
498
+ settle: false,
499
+ }),
480
500
  async waitForWallet(timeoutMs) {
481
501
  const deadline = Date.now() + timeoutMs;
482
502
  let stableCaptures = 0;
@@ -87,6 +87,7 @@ export function resolveVisibleStateOptions(node = {}) {
87
87
  maximumOrderCount: boundedInteger(node.maximum_order_count, 200, 0, 200, 'maximum_order_count'),
88
88
  minimumActivityCount: boundedInteger(node.minimum_activity_count, 0, 0, 200, 'minimum_activity_count'),
89
89
  requireNoHorizontalOverflow: node.require_no_horizontal_overflow === true,
90
+ requireNoControlOverlap: node.require_no_control_overlap === true,
90
91
  requireFeatures: stringList(node.require_features, 'require_features').map((feature) => {
91
92
  if (!FEATURES.has(feature)) {
92
93
  throw new Error(
@@ -143,6 +144,9 @@ function marketFromTestId(testId) {
143
144
 
144
145
  function classifySurface(route, testIds) {
145
146
  const joined = [...testIds].join('\n');
147
+ if (/confirm-transaction\/.*[?&]goBackTo=%2Fperps-home(?:&|$)/u.test(route)) {
148
+ return 'funds';
149
+ }
146
150
  if (/\/perps\/trade\//u.test(route)) {
147
151
  return 'order-entry';
148
152
  }
@@ -178,7 +182,7 @@ function visibleFeatures(items) {
178
182
  const short = has(/(?:short-cta-button|direction-short|market-details-short-button)$/u);
179
183
  return {
180
184
  activity: has(/^(?:transaction-card-|perps-(?:recent-activity|activity))/u),
181
- balance: has(/^perps-balance-dropdown/u),
185
+ balance: has(/^(?:perps-balance-dropdown|perps-market-(?:balance-value|available-balance-text))/u),
182
186
  chart: has(/^(?:chart_surface|tv_chart_container)$/u) ||
183
187
  has(/perps-(?:candlestick-chart|market-detail-chart|market-details-fullscreen-chart|pro-market-chart)/u),
184
188
  'long-short': long && short,
@@ -215,20 +219,20 @@ export function normalizeVisiblePerpsState(raw, options, platform) {
215
219
  ...(route ? { route } : {}),
216
220
  ...(text(raw.title) ? { title: text(raw.title) } : {}),
217
221
  balance: compactEntries(
218
- selected(unique, (item) => item.testId.startsWith('perps-balance-dropdown'), 1),
222
+ selected(unique, (item) => /^(?:perps-balance-dropdown|perps-market-balance-value)$/u.test(item.testId), 1),
219
223
  1,
220
224
  )[0] ?? null,
221
225
  markets: marketItems,
222
226
  positions: compactEntries(
223
- selected(unique, (item) => /^(?:position-card-|perps-position-row-|perps-position-size-value$|perps-pro-market-position-row-)/u.test(item.testId), options.maxItems),
227
+ selected(unique, (item) => /^(?:position-card-(?!roe-)|perps-position-row-|perps-position-size-value$|perps-pro-market-position-row-|perps-home-position-card-)/u.test(item.testId), options.maxItems),
224
228
  options.maxItems,
225
229
  ),
226
230
  orders: compactEntries(
227
- selected(unique, (item) => /^(?:order-card-|perps-order-row-)/u.test(item.testId), options.maxItems),
231
+ selected(unique, (item) => /^(?:order-card-|perps-order-row-|perps-home-order-card-)/u.test(item.testId), options.maxItems),
228
232
  options.maxItems,
229
233
  ),
230
234
  activity: compactEntries(
231
- selected(unique, (item) => /^(?:transaction-card-|perps-activity-row-)/u.test(item.testId), options.maxItems),
235
+ selected(unique, (item) => /^(?:transaction-card-|transaction-item(?:-|$)|perps-activity-row-)/u.test(item.testId), options.maxItems),
232
236
  options.maxItems,
233
237
  ),
234
238
  orderEntry: raw.orderEntry && typeof raw.orderEntry === 'object' ? {
@@ -269,6 +273,16 @@ export function normalizeVisiblePerpsState(raw, options, platform) {
269
273
  clippedTestIds: Array.isArray(raw.layout.clippedTestIds)
270
274
  ? raw.layout.clippedTestIds.map(text).filter(Boolean).slice(0, 100)
271
275
  : [],
276
+ obscuredControlTestIds: Array.isArray(raw.layout.obscuredControlTestIds)
277
+ ? raw.layout.obscuredControlTestIds.map(text).filter(Boolean).slice(0, 100)
278
+ : [],
279
+ overlappingControlPairs: Array.isArray(raw.layout.overlappingControlPairs)
280
+ ? raw.layout.overlappingControlPairs
281
+ .filter((pair) => Array.isArray(pair) && pair.length === 2)
282
+ .map((pair) => pair.map(text))
283
+ .filter((pair) => pair.every(Boolean))
284
+ .slice(0, 100)
285
+ : [],
272
286
  } : null,
273
287
  };
274
288
  assertVisibleState(state, options);
@@ -281,6 +295,12 @@ function assertVisibleState(state, options) {
281
295
  )) {
282
296
  throw new Error(`Visible Perps layout overflows horizontally: ${JSON.stringify(state.layout)}.`);
283
297
  }
298
+ if (options.requireNoControlOverlap && (
299
+ !state.layout || state.layout.obscuredControlTestIds.length > 0 ||
300
+ state.layout.overlappingControlPairs.length > 0
301
+ )) {
302
+ throw new Error(`Visible Perps controls overlap: ${JSON.stringify(state.layout)}.`);
303
+ }
284
304
  if (options.surface !== 'auto' && state.surface !== options.surface) {
285
305
  throw new Error(
286
306
  `Expected visible Perps surface ${options.surface}, but observed ${state.surface}.`,