@deeeed/metamask-harness 0.47.4 → 0.48.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 (146) hide show
  1. package/CHANGELOG.md +6 -0
  2. package/README.md +3 -2
  3. package/adapters/extension/lib/macos-focus.cjs +2 -2
  4. package/adapters/extension/live.sh +6 -8
  5. package/adapters/mobile/bridge-runtime/lib/bridge-errors.cjs +1 -0
  6. package/adapters/shared/ensure-runner-deps.sh +6 -0
  7. package/dist/adapters/extension/runtime-decision.js +18 -1
  8. package/dist/adapters/extension/runtime.js +5 -5
  9. package/dist/adapters/extension/surface.js +1 -0
  10. package/dist/app-lifecycle.js +3 -1
  11. package/dist/command-contract.js +2 -0
  12. package/dist/commands/call.js +1 -8
  13. package/dist/commands/device-target.js +12 -3
  14. package/dist/commands/doctor.js +7 -5
  15. package/dist/commands/fixtures.js +1 -1
  16. package/dist/commands/launch/index.js +10 -0
  17. package/dist/commands/manifest.js +3 -1
  18. package/dist/commands/mobile-device-view.js +13 -3
  19. package/dist/commands/parse-args.js +2 -0
  20. package/dist/commands/run-engine.js +29 -4
  21. package/dist/commands/run.js +1 -1
  22. package/dist/commands/runtime-launch.js +10 -1
  23. package/dist/commands/status.js +8 -4
  24. package/dist/devices.js +31 -9
  25. package/dist/execution-provenance.js +8 -7
  26. package/dist/funding-execution-context.js +43 -13
  27. package/dist/heal-bounds.js +3 -3
  28. package/dist/live-adapter-contract.js +4 -0
  29. package/dist/manifest.js +2 -13
  30. package/dist/mm-harness-cli.js +2 -1
  31. package/dist/runner.js +14 -3
  32. package/docs/RECIPES.md +133 -22
  33. package/docs/RELEASE-QA-CAPABILITY-MAP.md +3 -2
  34. package/library/actions/core/perps/_controller.mjs +10 -0
  35. package/library/actions/core/perps/close_orders.mjs +13 -17
  36. package/library/actions/extension/assets/home-token-identity.mjs +30 -0
  37. package/library/actions/extension/assets/import_custom_token.mjs +61 -79
  38. package/library/actions/extension/assets/open_details.mjs +18 -7
  39. package/library/actions/extension/assets/read_details.mjs +61 -29
  40. package/library/actions/extension/assets/read_visible_state.mjs +4 -1
  41. package/library/actions/extension/assets/select_network_scope.mjs +16 -9
  42. package/library/actions/extension/assets/set_token_visibility.mjs +84 -17
  43. package/library/actions/extension/assets/verify_sorting.mjs +62 -14
  44. package/library/actions/extension/networks/add_custom.mjs +19 -17
  45. package/library/actions/extension/networks/custom_network.mjs +8 -3
  46. package/library/actions/extension/networks/read_visible_state.mjs +1 -0
  47. package/library/actions/extension/performance/_navigation-memory.mjs +35 -6
  48. package/library/actions/extension/performance/compare_idle_navigation_memory.mjs +3 -2
  49. package/library/actions/extension/performance/measure_detached_dom.mjs +3 -2
  50. package/library/actions/extension/performance/measure_navigation_memory.mjs +3 -2
  51. package/library/actions/extension/perps/assert_visible_consistency.mjs +9 -4
  52. package/library/actions/extension/perps/close_visible_position.mjs +3 -3
  53. package/library/actions/extension/perps/edit_margin.mjs +1 -1
  54. package/library/actions/extension/perps/open_balance_action.mjs +16 -7
  55. package/library/actions/extension/perps/perps.mjs +150 -45
  56. package/library/actions/extension/perps/read_funds_confirmation.mjs +8 -0
  57. package/library/actions/extension/perps/read_snapshot.mjs +2 -1
  58. package/library/actions/extension/perps/read_visible_state.mjs +10 -6
  59. package/library/actions/extension/perps/search_markets.mjs +7 -2
  60. package/library/actions/extension/perps/select_activity_filter.mjs +1 -1
  61. package/library/actions/extension/perps/select_market_filter.mjs +74 -45
  62. package/library/actions/extension/perps/set_market_favorite.mjs +6 -3
  63. package/library/actions/extension/perps/state.mjs +1 -0
  64. package/library/actions/extension/perps/update_position_tpsl.mjs +1 -1
  65. package/library/actions/extension/perps/visible-mutation-identity.mjs +4 -2
  66. package/library/actions/extension/platform/cdp.mjs +23 -142
  67. package/library/actions/extension/swap_bridge/quote-state.mjs +54 -0
  68. package/library/actions/extension/swap_bridge/read_visible_state.mjs +4 -1
  69. package/library/actions/extension/swap_bridge/select_assets.mjs +11 -1
  70. package/library/actions/extension/swap_bridge/set_amount.mjs +10 -8
  71. package/library/actions/extension/swap_bridge/set_max_amount.mjs +17 -18
  72. package/library/actions/extension/swap_bridge/set_slippage.mjs +0 -9
  73. package/library/actions/extension/ui/navigate.mjs +58 -14
  74. package/library/actions/extension/wallet/lock.mjs +2 -1
  75. package/library/actions/mobile/assets/import_custom_token.mjs +1 -0
  76. package/library/actions/mobile/assets/read_visible_state.mjs +3 -1
  77. package/library/actions/mobile/assets/set_token_visibility.mjs +13 -9
  78. package/library/actions/mobile/networks/network-management.mjs +22 -15
  79. package/library/actions/mobile/networks/remove_custom.mjs +6 -2
  80. package/library/actions/mobile/perps/perps.mjs +3 -0
  81. package/library/actions/mobile/perps/read-visible-state-loop.mjs +1 -0
  82. package/library/actions/mobile/perps/set_market_favorite.mjs +15 -19
  83. package/library/actions/mobile/platform/native-session.mjs +26 -17
  84. package/library/actions/mobile/platform/observe-ui.mjs +2 -2
  85. package/library/actions/mobile/ui/navigate.mjs +1 -1
  86. package/library/actions/shared/perps/visible-state.mjs +14 -3
  87. package/library/manifests/core.action-manifest.json +14 -2
  88. package/library/manifests/extension.action-manifest.json +49 -16
  89. package/library/manifests/mobile.action-manifest.json +8 -7
  90. package/library/recipes/core/perps/smoke.recipe.json +23 -0
  91. package/library/recipes/core/perps/snapshot.recipe.json +26 -1
  92. package/library/recipes/extension/assets/release-custom-network-token-persistence.recipe.json +25 -8
  93. package/library/recipes/extension/performance/navigation-memory-suite.recipe.json +219 -0
  94. package/library/recipes/extension/performance/navigation-memory.recipe.json +61 -156
  95. package/library/recipes/extension/wallet/smoke.recipe.json +17 -8
  96. package/library/recipes/mobile/app/lifecycle.android-smoke.recipe.json +23 -4
  97. package/library/recipes/mobile/networks/release-custom-network-persistence.recipe.json +31 -6
  98. package/library/recipes/mobile/networks/release-multichain-visibility.recipe.json +5 -4
  99. package/library/recipes/mobile/wallet/smoke.recipe.json +17 -8
  100. package/library/recipes/{assets → shared/assets}/release-token-address-import.recipe.json +7 -0
  101. package/library/recipes/{assets → shared/assets}/release-token-details.recipe.json +2 -2
  102. package/library/recipes/shared/assets/release-token-hide-readd.recipe.json +91 -0
  103. package/library/recipes/{assets → shared/assets}/release-token-persistence.recipe.json +3 -0
  104. package/library/recipes/{assets → shared/assets}/release-token-search-import.recipe.json +4 -1
  105. package/library/recipes/{assets → shared/assets}/release-token-sorting.recipe.json +13 -2
  106. package/library/recipes/{swap-bridge → shared/swap-bridge}/release-quote.recipe.json +29 -3
  107. package/package.json +3 -3
  108. package/site/assets/help-recipes.json +63 -23
  109. package/library/recipes/assets/release-token-hide-readd.recipe.json +0 -57
  110. package/library/recipes/core/perps/clean-market-testnet.recipe.json +0 -44
  111. package/library/recipes/core/perps/order-lifecycle.recipe.json +0 -82
  112. package/library/recipes/core/perps/read-markets.recipe.json +0 -36
  113. package/library/recipes/core/perps/trading-lifecycle.recipe.json +0 -80
  114. package/library/recipes/extension/perps/release-activity-filters.recipe.json +0 -51
  115. package/library/recipes/extension/perps/release-funds-flows.recipe.json +0 -76
  116. package/library/recipes/extension/perps/release-live-limit-order.recipe.json +0 -109
  117. package/library/recipes/extension/perps/release-market-filters.recipe.json +0 -97
  118. package/library/recipes/extension/perps/release-order-entry.recipe.json +0 -158
  119. package/library/recipes/extension/perps/release-order-validation.recipe.json +0 -259
  120. package/library/recipes/extension/perps/release-visible-consistency.recipe.json +0 -47
  121. package/library/recipes/extension/perps/source-dev-snapshot-consistency.recipe.json +0 -76
  122. package/library/recipes/mobile/perps/chase-assert-running.recipe.json +0 -110
  123. package/library/recipes/mobile/perps/chase-place.recipe.json +0 -145
  124. package/library/recipes/mobile/perps/chase-terminate.recipe.json +0 -98
  125. package/library/recipes/mobile/perps/performance.recipe.json +0 -722
  126. package/library/recipes/mobile/perps/pro-order-setup.recipe.json +0 -111
  127. package/library/recipes/mobile/perps/pro-order-start-state.recipe.json +0 -53
  128. package/library/recipes/mobile/perps/scale-assert-orders.recipe.json +0 -126
  129. package/library/recipes/mobile/perps/scale-place.recipe.json +0 -186
  130. package/library/recipes/mobile/perps/twap-assert-active.recipe.json +0 -97
  131. package/library/recipes/mobile/perps/twap-place.recipe.json +0 -146
  132. package/library/recipes/mobile/runner/action-validation.recipe.json +0 -336
  133. package/library/recipes/perps/clean-market-testnet.recipe.json +0 -49
  134. package/library/recipes/perps/lifecycle.recipe.json +0 -144
  135. package/library/recipes/perps/release-live-market-order.recipe.json +0 -139
  136. package/library/recipes/perps/release-watchlist.recipe.json +0 -111
  137. package/library/recipes/swap-bridge/release-max-quote.recipe.json +0 -72
  138. /package/library/recipes/{assets → shared/assets}/release-token-list.recipe.json +0 -0
  139. /package/library/recipes/{perps → shared/perps}/release-market-details.recipe.json +0 -0
  140. /package/library/recipes/{perps → shared/perps}/release-market-search.recipe.json +0 -0
  141. /package/library/recipes/{swap-bridge → shared/swap-bridge}/release-custom-slippage.recipe.json +0 -0
  142. /package/library/recipes/{swap-bridge → shared/swap-bridge}/release-live-max-swap.recipe.json +0 -0
  143. /package/library/recipes/{swap-bridge → shared/swap-bridge}/release-live-swap.recipe.json +0 -0
  144. /package/library/recipes/{swap-bridge → shared/swap-bridge}/release-surface.recipe.json +0 -0
  145. /package/library/recipes/{wallet → shared/wallet}/import.recipe.json +0 -0
  146. /package/library/recipes/{wallet → shared/wallet}/reset-import.recipe.json +0 -0
@@ -1,5 +1,7 @@
1
1
  import { runAdapter, withExtensionPage } from '../platform/cdp.mjs';
2
2
  import { resolveAmountOptions } from '../../shared/swap-bridge/transaction.mjs';
3
+ import { requireVisibleObservation } from '../../shared/ui/locators.mjs';
4
+ import { readQuoteInputIdentity } from './quote-state.mjs';
3
5
 
4
6
  async function pause(page) {
5
7
  await page.evaluate('new Promise((resolve) => setTimeout(resolve, 250))');
@@ -11,21 +13,11 @@ runAdapter(async (input) => withExtensionPage(input, async (page) => {
11
13
  wait_for_quote: input.node?.wait_for_quote,
12
14
  timeout_ms: input.node?.timeout_ms,
13
15
  });
14
- const marker = `mm-harness-${Date.now()}-${Math.random().toString(16).slice(2)}`;
15
- const selector = await page.evaluate(`(() => {
16
- const marker = ${JSON.stringify(marker)};
17
- for (const element of document.querySelectorAll('button, [role="button"]')) {
18
- const label = String(element.innerText || element.textContent || '').replace(/\\s+/gu, ' ').trim();
19
- const style = getComputedStyle(element);
20
- const rect = element.getBoundingClientRect();
21
- if (label !== 'Max' || style.display === 'none' || style.visibility === 'hidden' || rect.width <= 0 || rect.height <= 0) continue;
22
- element.setAttribute('data-mm-harness-target', marker);
23
- return '[data-mm-harness-target="' + marker + '"]';
24
- }
25
- return null;
26
- })()`);
27
- if (!selector) throw new Error('Swap Max control was not visible.');
28
- await page.click(selector);
16
+ const visible = requireVisibleObservation(await page.observe(['ui.visible']));
17
+ const controls = visible.items.filter((item) =>
18
+ item.label === 'Max' && item.selector && item.enabled !== false);
19
+ if (controls.length !== 1) throw new Error('Swap requires exactly one visible, enabled Max control.');
20
+ await page.click(controls[0].selector);
29
21
  const deadline = Date.now() + options.timeoutMs;
30
22
  let observed = null;
31
23
  while (Date.now() < deadline) {
@@ -45,14 +37,21 @@ runAdapter(async (input) => withExtensionPage(input, async (page) => {
45
37
  noQuote: document.body.innerText.includes("This trade route isn't available right now"),
46
38
  };
47
39
  })()`);
40
+ if (options.waitForQuote) {
41
+ observed.quoteIdentity = await readQuoteInputIdentity(page);
42
+ if (observed.quoteIdentity.sourceAmount !== observed.sourceAmount || observed.quoteIdentity.destinationAmount !== observed.destinationAmount) observed.quoteIdentity.status = 'stale';
43
+ }
44
+ if (observed.quoteIdentity?.status === 'matching' && !observed.loading && !observed.ctaEnabled && /^Insufficient funds$/iu.test(String(observed.cta))) {
45
+ throw new Error(`Swap Max quote matches the input but cannot be submitted: ${JSON.stringify(observed)}.`);
46
+ }
48
47
  if (Number(observed.sourceAmount) > 0
49
- && (!options.waitForQuote || observed.noQuote || (!observed.loading && Number(observed.destinationAmount) > 0 && observed.ctaEnabled))) break;
48
+ && (!options.waitForQuote || observed.noQuote || (observed.quoteIdentity?.status === 'matching' && !observed.loading && Number(observed.destinationAmount) > 0 && observed.ctaEnabled && /^(?:Swap|Bridge)$/iu.test(String(observed.cta))))) break;
50
49
  await pause(page);
51
50
  }
52
51
  if (!(Number(observed?.sourceAmount) > 0)) throw new Error('Swap Max did not populate a positive source amount.');
53
52
  if (options.waitForQuote && !observed.noQuote
54
- && (observed.loading || !(Number(observed.destinationAmount) > 0) || !observed.ctaEnabled)) {
55
- throw new Error(`Swap Max quote did not resolve within ${options.timeoutMs}ms.`);
53
+ && (observed.quoteIdentity?.status !== 'matching' || observed.loading || !(Number(observed.destinationAmount) > 0) || !observed.ctaEnabled || !/^(?:Swap|Bridge)$/iu.test(String(observed.cta)))) {
54
+ throw new Error(`Swap Max quote did not resolve within ${options.timeoutMs}ms: ${JSON.stringify(observed)}.`);
56
55
  }
57
56
  return { action: input.action, ...observed, proofPath: 'trusted-pointer-visible-ui' };
58
57
  }));
@@ -74,10 +74,8 @@ runAdapter(async (input) => withExtensionPage(input, async (page) => {
74
74
  if (!observed?.includes(`${percent}%`)) {
75
75
  throw new Error(`Swap quote did not display Slippage: ${percent}%.`);
76
76
  }
77
- await page.evaluate('new Promise((resolve) => setTimeout(resolve, 500))');
78
77
  let stableSince = null;
79
78
  let quote = null;
80
- let lastRefreshAt = 0;
81
79
  while (Date.now() < deadline) {
82
80
  quote = await page.evaluate(`(() => {
83
81
  const destination = document.querySelector('[data-testid="to-amount"]');
@@ -90,13 +88,6 @@ runAdapter(async (input) => withExtensionPage(input, async (page) => {
90
88
  ctaEnabled: Boolean(cta && !cta.disabled),
91
89
  };
92
90
  })()`);
93
- if (/^(?:Get new quote|Refresh quote)$/iu.test(quote.cta) && quote.ctaEnabled && Date.now() - lastRefreshAt >= 1000) {
94
- await page.click(dataTestId('bridge-cta-button'));
95
- lastRefreshAt = Date.now();
96
- stableSince = null;
97
- await pause(page);
98
- continue;
99
- }
100
91
  const ready = !quote.loading && Number(quote.destinationAmount) > 0 && quote.ctaEnabled
101
92
  && /^(?:Swap|Bridge)$/iu.test(quote.cta);
102
93
  if (ready) {
@@ -8,6 +8,7 @@ import {
8
8
  } from '../platform/cdp.mjs';
9
9
 
10
10
  const BACK_CONTROLS = [
11
+ 'custom-token-import-close-button',
11
12
  'perps-order-entry-back-button',
12
13
  'perps-market-detail-back-button',
13
14
  'perps-activity-back-button',
@@ -23,6 +24,18 @@ const BACK_CONTROLS = [
23
24
  ].map(dataTestId).concat('button[aria-label="Back"]');
24
25
  const UI_POLL_MS = 200;
25
26
  const PERPS_HOME_SETTLE_MS = 1000;
27
+ const PERPS_MARKET_LIST = [
28
+ 'market-list-view',
29
+ 'parent-selector-perps-market-list',
30
+ ].map(dataTestId).join(',');
31
+ const PERPS_MARKET_DETAIL = [
32
+ 'perps-market-detail-page',
33
+ 'parent-selector-perps-market-detail',
34
+ ].map(dataTestId).join(',');
35
+ const PERPS_ACTIVITY = [
36
+ 'perps-activity-page',
37
+ 'parent-selector-perps-activity',
38
+ ].map(dataTestId).join(',');
26
39
 
27
40
  function text(value) {
28
41
  return typeof value === 'string' && value.length > 0 ? value : undefined;
@@ -68,6 +81,7 @@ async function hasVisibleSelector(page, selector) {
68
81
  return page.evaluate(`(() => {
69
82
  const element = document.querySelector(${JSON.stringify(selector)});
70
83
  if (!element) return false;
84
+ if (element.closest('dialog')?.open === false) return false;
71
85
  const style = getComputedStyle(element);
72
86
  const rect = element.getBoundingClientRect();
73
87
  return style.display !== 'none' && style.visibility !== 'hidden' && rect.width > 0 && rect.height > 0;
@@ -116,6 +130,13 @@ export async function openHome(page, timeoutMs) {
116
130
  let pendingActivation;
117
131
 
118
132
  while (Date.now() < deadline) {
133
+ const importNetworkClose = '[data-testid="custom-token-import-network-selector"] button[aria-label="Close"]';
134
+ if (await hasVisibleSelector(page, importNetworkClose)) {
135
+ await activateSemanticControl(page, importNetworkClose);
136
+ steps.push(importNetworkClose);
137
+ await pauseForUi(page);
138
+ continue;
139
+ }
119
140
  if (await hasVisibleSelector(page, drawerClose)) {
120
141
  await activateSemanticControl(page, drawerClose);
121
142
  steps.push(drawerClose);
@@ -274,11 +295,11 @@ export async function openPerpsHome(page, timeoutMs) {
274
295
  export async function openPerpsMarket(page, market, timeoutMs) {
275
296
  const deadline = Date.now() + timeoutMs;
276
297
  const normalizedMarket = normalizeMarketSymbol(market.trim());
277
- if (await hasVisibleSelector(page, dataTestId('market-list-view'))) {
298
+ if (await hasVisibleSelector(page, PERPS_MARKET_LIST)) {
278
299
  const marketRow = dataTestId(`market-row-${normalizedMarket.replace(':', '-')}`);
279
300
  await page.waitForSelector(marketRow, { timeoutMs: Math.max(1, deadline - Date.now()) });
280
301
  const navigation = await activateSemanticControl(page, marketRow);
281
- await page.waitForSelector(dataTestId('perps-market-detail-page'), {
302
+ await page.waitForSelector(PERPS_MARKET_DETAIL, {
282
303
  timeoutMs: Math.max(1, deadline - Date.now()),
283
304
  });
284
305
  return {
@@ -308,7 +329,7 @@ export async function openPerpsMarket(page, market, timeoutMs) {
308
329
  );
309
330
  }
310
331
  const navigation = await activateSemanticControl(page, marketControl);
311
- await page.waitForSelector(dataTestId('perps-market-detail-page'), {
332
+ await page.waitForSelector(PERPS_MARKET_DETAIL, {
312
333
  timeoutMs: Math.max(1, deadline - Date.now()),
313
334
  });
314
335
  return {
@@ -321,7 +342,7 @@ export async function openPerpsMarket(page, market, timeoutMs) {
321
342
  }
322
343
 
323
344
  export async function openPerpsActivity(page, timeoutMs) {
324
- if (await hasVisibleSelector(page, dataTestId('perps-activity-page'))) {
345
+ if (await hasVisibleSelector(page, PERPS_ACTIVITY)) {
325
346
  return { method: 'visible-ui', href: await currentHref(page), steps: [] };
326
347
  }
327
348
  const deadline = Date.now() + timeoutMs;
@@ -329,7 +350,7 @@ export async function openPerpsActivity(page, timeoutMs) {
329
350
  const activity = dataTestId('perps-recent-activity-see-all');
330
351
  await page.waitForSelector(activity, { timeoutMs: Math.max(1, deadline - Date.now()) });
331
352
  const navigation = await activateSemanticControl(page, activity);
332
- await page.waitForSelector(dataTestId('perps-activity-page'), {
353
+ await page.waitForSelector(PERPS_ACTIVITY, {
333
354
  timeoutMs: Math.max(1, deadline - Date.now()),
334
355
  });
335
356
  return {
@@ -342,7 +363,7 @@ export async function openPerpsActivity(page, timeoutMs) {
342
363
  }
343
364
 
344
365
  export async function openPerpsMarketList(page, timeoutMs) {
345
- const marketList = dataTestId('market-list-view');
366
+ const marketList = PERPS_MARKET_LIST;
346
367
  if (await hasVisibleSelector(page, marketList)) {
347
368
  return { method: 'visible-ui', href: await currentHref(page), steps: [] };
348
369
  }
@@ -417,17 +438,37 @@ export async function openNetwork(page, network, chainId, addIfMissing, timeoutM
417
438
  throw new Error('Token assets did not become selected before network navigation.');
418
439
  }
419
440
  }
420
- await activateSemanticControl(page, sort);
421
- steps.push(sort);
441
+ if (!(await hasVisibleSelector(page, dataTestId('home-network-filter-all-default')))) {
442
+ await activateSemanticControl(page, sort);
443
+ steps.push(sort);
444
+ }
422
445
  let choice = await waitForFirstVisible(page, [installed, custom, additional, manage], deadline);
423
446
  if (!choice) throw new Error(`Network filter did not show ${network} (${chainId}) before timeout.`);
424
- if (choice !== manage) {
447
+ if (!addIfMissing && (choice === additional || choice === manage)) {
448
+ throw new Error(`Network ${network} (${chainId}) is not added to the wallet.`);
449
+ }
450
+ let added = false;
451
+ if (choice === additional) {
452
+ await page.scroll({ selector: additional, intoView: true });
453
+ await page.waitFor({ selector: additional, expected: 'visible', timeoutMs: Math.max(1, deadline - Date.now()) });
454
+ await activateSemanticControl(page, additional);
455
+ steps.push(additional);
456
+ await page.waitFor({ selector: additional, expected: 'hidden', timeoutMs: Math.max(1, deadline - Date.now()) });
457
+ await activateSemanticControl(page, sort);
458
+ steps.push(sort);
459
+ choice = await waitForFirstVisible(page, [installed, custom], deadline);
460
+ if (!choice) throw new Error(`Added network ${network} (${chainId}) did not appear in the wallet filter.`);
461
+ await page.scroll({ selector: choice, intoView: true });
462
+ await page.waitFor({ selector: choice, expected: 'visible', timeoutMs: Math.max(1, deadline - Date.now()) });
463
+ await activateSemanticControl(page, choice);
464
+ steps.push(choice);
465
+ added = true;
466
+ } else if (choice !== manage) {
467
+ await page.scroll({ selector: choice, intoView: true });
468
+ await page.waitFor({ selector: choice, expected: 'visible', timeoutMs: Math.max(1, deadline - Date.now()) });
425
469
  await activateSemanticControl(page, choice);
426
470
  steps.push(choice);
427
471
  } else {
428
- if (!addIfMissing) {
429
- throw new Error(`Network ${network} (${chainId}) is not added to the wallet.`);
430
- }
431
472
  await activateSemanticControl(page, manage);
432
473
  steps.push(manage);
433
474
  const listItem = dataTestId(`network-list-item-eip155:${chainId}`);
@@ -437,6 +478,7 @@ export async function openNetwork(page, network, chainId, addIfMissing, timeoutM
437
478
  await activateSemanticControl(page, popular);
438
479
  steps.push(popular);
439
480
  await page.waitForSelector(listItem, { timeoutMs: Math.max(1, deadline - Date.now()) });
481
+ added = true;
440
482
  }
441
483
  const back = dataTestId('page-header-back-button');
442
484
  await activateSemanticControl(page, back);
@@ -444,15 +486,17 @@ export async function openNetwork(page, network, chainId, addIfMissing, timeoutM
444
486
  await page.waitForSelector(sort, { timeoutMs: Math.max(1, deadline - Date.now()) });
445
487
  await activateSemanticControl(page, sort);
446
488
  steps.push(sort);
447
- choice = await waitForFirstVisible(page, [installed, custom, additional], deadline);
489
+ choice = await waitForFirstVisible(page, [installed, custom], deadline);
448
490
  if (!choice) throw new Error(`Added network ${network} (${chainId}) did not appear in the wallet filter.`);
491
+ await page.scroll({ selector: choice, intoView: true });
492
+ await page.waitFor({ selector: choice, expected: 'visible', timeoutMs: Math.max(1, deadline - Date.now()) });
449
493
  await activateSemanticControl(page, choice);
450
494
  steps.push(choice);
451
495
  }
452
496
  while (Date.now() < deadline) {
453
497
  const label = await page.evaluate(`String(document.querySelector(${JSON.stringify(sort)})?.innerText || '')`);
454
498
  if (label.toLowerCase().includes(network.toLowerCase())) {
455
- return { method: 'visible-ui', href: await currentHref(page), network, chainId, steps };
499
+ return { method: 'visible-ui', href: await currentHref(page), network, chainId, added, steps };
456
500
  }
457
501
  await pauseForUi(page);
458
502
  }
@@ -21,6 +21,7 @@ async function clickUncovered(page, selector, deadline) {
21
21
  point = await page.evaluate(`(() => {
22
22
  const element = document.querySelector(${JSON.stringify(selector)});
23
23
  if (!element) return null;
24
+ element.scrollIntoView({ block: 'center', inline: 'center' });
24
25
  const style = getComputedStyle(element);
25
26
  const rect = element.getBoundingClientRect();
26
27
  if (style.display === 'none' || style.visibility === 'hidden' || rect.width <= 0 || rect.height <= 0) return null;
@@ -74,7 +75,7 @@ export async function lockWallet(page, timeoutMs = 15_000) {
74
75
  if (Date.now() >= deadline) throw new Error('Wallet lock control did not become visible.');
75
76
  await page.evaluate('new Promise((resolve) => setTimeout(resolve, 100))');
76
77
  }
77
- await page.click(LOCK);
78
+ await clickUncovered(page, LOCK, deadline);
78
79
  while (!(await visible(page, UNLOCK_PAGE))) {
79
80
  if (Date.now() >= deadline) throw new Error('Wallet did not reach the unlock page after locking.');
80
81
  await page.evaluate('new Promise((resolve) => setTimeout(resolve, 100))');
@@ -168,6 +168,7 @@ runAdapter(async (input) => {
168
168
  const token = await findTokenRow(session, expectedSymbol, timeoutMs);
169
169
  return {
170
170
  action: input.action,
171
+ changed: true,
171
172
  network,
172
173
  chainId,
173
174
  address,
@@ -17,7 +17,7 @@ runAdapter(async (input) => {
17
17
  const warning = observed.warnings?.map((entry) => entry.message).filter(Boolean).join('; ');
18
18
  throw new Error(`Mobile visible Assets state is unavailable${warning ? `: ${warning}` : '.'}`);
19
19
  }
20
- const items = [...(visible.items ?? []), ...(visible.hidden_or_offscreen ?? [])]
20
+ const items = (visible.items ?? [])
21
21
  .filter((item) => String(item.test_id ?? '').startsWith('asset-'))
22
22
  .map((item) => ({
23
23
  testId: item.test_id,
@@ -32,6 +32,8 @@ runAdapter(async (input) => {
32
32
  items,
33
33
  truncated: visible.truncated,
34
34
  }, options, 'mobile'),
35
+ observationProvider: visible.provider ?? screen.provider,
36
+ deviceId: screen.deviceId,
35
37
  proofPath: 'native-accessibility',
36
38
  };
37
39
  });
@@ -25,25 +25,24 @@ function descendants(snapshot, root) {
25
25
 
26
26
  function searchedToken(snapshot, query) {
27
27
  const normalized = query.toUpperCase();
28
- const rows = snapshot.nodes.filter((node) => node.identifier === 'searched-token-result');
28
+ const rows = snapshot.nodes.filter((node) => node.identifier === 'searched-token-result' && node.visibleToUser !== false);
29
+ const matches = [];
29
30
  for (const row of rows) {
30
31
  const children = descendants(snapshot, row);
31
- const label = children.map(nativeLabel).find((value) => {
32
+ const label = [nativeLabel(row), ...children.map(nativeLabel)].find((value) => {
32
33
  const parts = value.split(',').map((part) => part.trim().toUpperCase());
33
34
  return parts.includes(normalized);
34
35
  });
35
36
  if (label) {
36
- return {
37
+ matches.push({
37
38
  row,
38
39
  label,
39
40
  imported: row.enabled === false && children.some((node) => /CheckBox$/u.test(String(node.type))),
40
- };
41
+ });
41
42
  }
42
43
  }
43
- const imported = rows.find((row) =>
44
- row.enabled === false && descendants(snapshot, row).some((node) => /CheckBox$/u.test(String(node.type)))
45
- );
46
- return imported ? { row: imported, label: query, imported: true } : null;
44
+ if (matches.length > 1) throw new Error(`Mobile token search matched ${matches.length} rows for ${query}; token identity is ambiguous.\nNext: mm-harness actions --action metamask.assets.set_token_visibility --json`);
45
+ return matches[0] ?? null;
47
46
  }
48
47
 
49
48
  function visibleNode(snapshot, identifier) {
@@ -201,7 +200,9 @@ async function findVisibleAsset(session, query, timeoutMs) {
201
200
  )).snapshot;
202
201
  }
203
202
  for (let attempt = 0; attempt < 8; attempt += 1) {
204
- if (nativeNode(snapshot, `asset-${query.toUpperCase()}`)) return true;
203
+ const matches = snapshot.nodes.filter(node => node.identifier === `asset-${query.toUpperCase()}` && node.visibleToUser !== false);
204
+ if (matches.length > 1) throw new Error(`Mobile token list matched ${matches.length} rows for ${query}; removal is ambiguous.\nNext: mm-harness actions --action metamask.assets.set_token_visibility --json`);
205
+ if (matches.length === 1) return true;
205
206
  await session.execute('ui.scroll', { direction: 'down', timeout_ms: timeoutMs, settle: false });
206
207
  snapshot = await session.snapshot();
207
208
  }
@@ -271,6 +272,9 @@ async function disableToken(session, query, requireUnchanged, requireChange, tim
271
272
  }
272
273
 
273
274
  runAdapter(async (input) => {
275
+ if (String(input.node?.asset_id ?? '').trim()) {
276
+ throw new Error('Exact asset-ID corroboration is not supported on Mobile.\nNext: mm-harness actions --adapter mobile --action metamask.assets.set_token_visibility --json');
277
+ }
274
278
  const query = String(input.node?.query ?? '').trim();
275
279
  const timeoutMs = Number(input.node?.timeout_ms ?? 45_000);
276
280
  const requested = input.node?.visible;
@@ -26,6 +26,10 @@ export async function waitForSnapshot(session, timeoutMs, predicate, message) {
26
26
  const deadline = Date.now() + timeoutMs;
27
27
  while (Date.now() <= deadline) {
28
28
  const snapshot = await session.snapshot();
29
+ const labels = snapshot.nodes.map(nativeLabel);
30
+ if (labels.includes('Reload') && labels.includes('Go home') && labels.includes('Tools button')) {
31
+ throw new Error('Mobile network navigation opened the Expo developer menu instead of the requested control.\nNext: mm-harness call ui.key_press key=BACK --adapter mobile');
32
+ }
29
33
  const value = predicate(snapshot);
30
34
  if (value) return { snapshot, value };
31
35
  await new Promise((resolve) => setTimeout(resolve, 200));
@@ -42,23 +46,26 @@ async function systemBack(session) {
42
46
  }
43
47
 
44
48
  export async function openNetworkManager(session, timeoutMs) {
45
- for (let attempt = 0; attempt < 8; attempt += 1) {
46
- const snapshot = await session.snapshot();
47
- if (visibleNode(snapshot, IDS.manager)) return snapshot;
48
- if (visibleNode(snapshot, IDS.details)) {
49
- await systemBack(session);
50
- continue;
51
- }
52
- if (visibleNode(snapshot, IDS.menu)) {
53
- await session.execute('ui.press', { test_id: IDS.menu, timeout_ms: timeoutMs, settle: false });
54
- continue;
55
- }
56
- if (IDS.wallet.some((identifier) => nativeNode(snapshot, identifier))) {
57
- await session.execute('ui.press', { test_id: IDS.hamburger, timeout_ms: timeoutMs, settle: false });
58
- continue;
59
- }
49
+ const snapshot = await session.snapshot();
50
+ if (visibleNode(snapshot, IDS.manager)) return snapshot;
51
+ if (visibleNode(snapshot, IDS.details)) {
60
52
  await systemBack(session);
53
+ return (await waitForSnapshot(
54
+ session, timeoutMs, (current) => visibleNode(current, IDS.manager),
55
+ 'Mobile Networks settings did not return from network details.',
56
+ )).snapshot;
57
+ }
58
+ if (!visibleNode(snapshot, IDS.menu)) {
59
+ if (!IDS.wallet.some((identifier) => nativeNode(snapshot, identifier))) {
60
+ throw new Error('Mobile network navigation requires Home, the account menu, or network settings.');
61
+ }
62
+ await session.execute('ui.press', { test_id: IDS.hamburger, timeout_ms: timeoutMs, settle: false });
63
+ await waitForSnapshot(
64
+ session, timeoutMs, (current) => visibleNode(current, IDS.menu),
65
+ 'The Mobile account menu did not expose the Networks control after opening.',
66
+ );
61
67
  }
68
+ await session.execute('ui.press', { test_id: IDS.menu, timeout_ms: timeoutMs, settle: false });
62
69
  return (await waitForSnapshot(
63
70
  session,
64
71
  timeoutMs,
@@ -32,6 +32,8 @@ runAdapter(async (input) => {
32
32
  (snapshot) => {
33
33
  const container = nativeNode(snapshot, 'network-details-view-container');
34
34
  if (!container) return null;
35
+ const remove = nativeNode(snapshot, 'network-details-view-remove-network-button');
36
+ if (remove?.enabled === true && remove.visibleToUser !== false && remove.hittable === true) return remove;
35
37
  const labeled = snapshot.nodes.find((node) =>
36
38
  node.hittable === true && /delete|remove|trash/iu.test(nativeLabel(node))
37
39
  );
@@ -84,8 +86,10 @@ runAdapter(async (input) => {
84
86
  (snapshot) => nativeNode(snapshot, 'networks-settings-container'),
85
87
  'Mobile Networks settings did not reopen after deletion.',
86
88
  );
87
- if (await findNetwork(session, network, chainId, timeoutMs)) {
88
- throw new Error(`${network} (${chainId}) remained after deletion.`);
89
+ const removalDeadline = Date.now() + timeoutMs;
90
+ while (await findNetwork(session, network, chainId, Math.max(1, removalDeadline - Date.now()))) {
91
+ if (Date.now() >= removalDeadline) throw new Error(`${network} (${chainId}) remained after deletion.`);
92
+ await new Promise((resolve) => setTimeout(resolve, 250));
89
93
  }
90
94
  return {
91
95
  action: input.action,
@@ -1770,6 +1770,9 @@ async function applyTutorialState(input, config) {
1770
1770
 
1771
1771
 
1772
1772
  export async function startState(input) {
1773
+ if (input.node?.account !== undefined && (typeof input.node.account !== 'string' || !input.node.account.trim())) {
1774
+ throw new Error('Perps start state requires a non-empty named account when account is supplied.\nNext: mm-harness actions metamask.perps.start_state --adapter mobile --json');
1775
+ }
1773
1776
  const params = paramsForState(input);
1774
1777
  const config = applyTradingStatePolicy(
1775
1778
  mergeStateConfig(profileDefaults(params.profile), params),
@@ -47,6 +47,7 @@ export async function readVisiblePerpsState(
47
47
  truncated: visible.truncated,
48
48
  }, options, 'mobile'),
49
49
  proofPath: 'native-accessibility',
50
+ observationProvider: visible.provider,
50
51
  };
51
52
  } catch (error) {
52
53
  lastAssertionError = error;
@@ -11,7 +11,14 @@ const FAVORITE_IDS = [
11
11
  'perps-pro-market-header-favorite-button',
12
12
  ];
13
13
 
14
- function favoriteControl(snapshot) {
14
+ function favoriteControl(snapshot, market) {
15
+ const subtitle = nativeLabel(
16
+ nativeNode(snapshot, 'perps-market-header-subtitle')
17
+ ?? nativeNode(snapshot, 'perps-pro-market-header-subtitle'),
18
+ ).toUpperCase();
19
+ if (!subtitle.startsWith(`${market}-`)) {
20
+ throw new Error(`Visible Perps market ${JSON.stringify(subtitle)} does not match ${market}.`);
21
+ }
15
22
  for (const testId of FAVORITE_IDS) {
16
23
  const node = nativeNode(snapshot, testId);
17
24
  if (/(?:add to|remove from) watchlist/iu.test(nativeLabel(node))) {
@@ -39,18 +46,11 @@ runAdapter(async (input) => {
39
46
  if (typeof desired !== 'boolean') {
40
47
  throw new Error('metamask.perps.set_market_favorite requires favorite=true or false.');
41
48
  }
42
- let session = createNativeSession(input, 'Perps', `favorite-${process.pid}`);
49
+ const session = createNativeSession(input, 'Perps', `favorite-${process.pid}`);
43
50
  try {
44
51
  await session.open();
45
52
  let snapshot = await session.snapshot();
46
- const subtitle = nativeLabel(
47
- nativeNode(snapshot, 'perps-market-header-subtitle')
48
- ?? nativeNode(snapshot, 'perps-pro-market-header-subtitle'),
49
- ).toUpperCase();
50
- if (!subtitle.startsWith(`${market}-`)) {
51
- throw new Error(`Visible Perps market ${JSON.stringify(subtitle)} does not match ${market}.`);
52
- }
53
- let control = favoriteControl(snapshot);
53
+ let control = favoriteControl(snapshot, market);
54
54
  const controlDeadline = Date.now() + timeoutMs;
55
55
  let lastControls = [];
56
56
  while (Date.now() <= controlDeadline && !control) {
@@ -60,37 +60,33 @@ runAdapter(async (input) => {
60
60
  .filter((node) => /(?:favorite|mode-toggle|market-header)/iu.test(String(node.identifier ?? '')))
61
61
  .map((node) => ({ id: String(node.identifier ?? ''), label: nativeLabel(node) }))
62
62
  .slice(0, 20);
63
- control = favoriteControl(snapshot);
63
+ control = favoriteControl(snapshot, market);
64
64
  }
65
65
  if (!control) {
66
66
  throw new Error(`Perps favorite control is not visible in Lite or Pro mode; observed ${JSON.stringify(lastControls)}.`);
67
67
  }
68
68
  const before = favoriteState(control);
69
+ if (requireUnchanged && before !== desired) {
70
+ throw new Error(`Perps favorite state for ${market} did not persist across navigation; no change was made.`);
71
+ }
69
72
  if (before !== desired) {
70
73
  await session.execute('ui.press', {
71
74
  test_id: control.testId,
72
75
  timeout_ms: timeoutMs,
73
76
  settle: false,
74
77
  });
75
- await closeNativeSession(session);
76
- await new Promise((resolve) => setTimeout(resolve, 500));
77
- session = createNativeSession(input, 'Perps', `favorite-proof-${process.pid}`);
78
- await session.open();
79
78
  }
80
79
  const deadline = Date.now() + timeoutMs;
81
80
  let after = before;
82
81
  while (Date.now() <= deadline) {
83
82
  snapshot = await session.snapshot();
84
- control = favoriteControl(snapshot);
83
+ control = favoriteControl(snapshot, market);
85
84
  if (control) after = favoriteState(control);
86
85
  if (after === desired) break;
87
86
  await new Promise((resolve) => setTimeout(resolve, 200));
88
87
  }
89
88
  if (after !== desired) throw new Error(`Perps favorite state did not become ${desired}.`);
90
89
  const changed = before !== after;
91
- if (requireUnchanged && changed) {
92
- throw new Error(`Perps favorite state for ${market} did not persist across navigation.`);
93
- }
94
90
  return {
95
91
  action: input.action,
96
92
  market,
@@ -1,6 +1,9 @@
1
1
  import { createAgentDeviceUiTransport } from '@farmslot/expo-recipe';
2
2
  import { createAgentDeviceClient } from 'agent-device';
3
3
 
4
+ import { resolveMobileTarget } from './bridge.mjs';
5
+ import { hideAccessibilityMaskingHud } from './observe-ui.mjs';
6
+
4
7
  import {
5
8
  nativeAgentDeviceStateDir,
6
9
  nativeSessionName,
@@ -8,17 +11,27 @@ import {
8
11
 
9
12
  export function createNativeSession(input, domain, sessionSuffix = '') {
10
13
  const env = { ...process.env, ...(input.context?.env ?? {}) };
11
- const device = String(env.ADB_SERIAL ?? env.ANDROID_SERIAL ?? '').trim();
12
- if (!device) throw new Error(`Mobile ${domain} native actions require one explicit Android device serial.`);
13
- const app = String(env.ANDROID_PACKAGE_ID ?? 'io.metamask');
14
+ const target = resolveMobileTarget(input);
15
+ const platform = target.requestedPlatform || (target.adbSerial ? 'android' : 'ios');
16
+ const device = String(platform === 'android'
17
+ ? target.adbSerial ?? ''
18
+ : input.node?.sim_udid ?? env.SIM_UDID ?? target.iosSimulator ?? '').trim();
19
+ if (!device) throw new Error(`Mobile ${domain} native actions require one explicit ${platform} device. Next: pass --device <device>.`);
20
+ const app = String(platform === 'android'
21
+ ? env.ANDROID_PACKAGE_ID ?? 'io.metamask'
22
+ : env.IOS_BUNDLE_ID ?? 'io.metamask.MetaMask');
14
23
  const baseSession = nativeSessionName(env, device, app);
15
24
  const session = sessionSuffix
16
25
  ? `${baseSession}-${String(sessionSuffix).replace(/[^A-Za-z0-9._-]/gu, '-')}`.slice(0, 120)
17
26
  : baseSession;
18
27
  const stateDir = nativeAgentDeviceStateDir(env, input.context.projectRoot, device);
19
- const client = createAgentDeviceClient({ session, stateDir, lockPolicy: 'reject', lockPlatform: 'android' });
20
- const selection = { platform: 'android', target: 'mobile', serial: device, session };
21
- const transport = createAgentDeviceUiTransport({ platform: 'android', device, app, session, stateDir, client });
28
+ const client = createAgentDeviceClient({ session, stateDir, lockPolicy: 'reject', lockPlatform: platform });
29
+ const selection = {
30
+ platform, target: 'mobile', session,
31
+ ...(platform === 'android' ? { serial: device }
32
+ : /^[0-9a-f]{8}(?:-[0-9a-f]{4}){3}-[0-9a-f]{12}$/iu.test(device) ? { udid: device } : { device }),
33
+ };
34
+ const transport = createAgentDeviceUiTransport({ platform, device, app, session, stateDir, client });
22
35
  const context = {
23
36
  nodeId: String(input.context?.nodeId ?? 'action'),
24
37
  projectRoot: input.context.projectRoot,
@@ -31,23 +44,19 @@ export function createNativeSession(input, domain, sessionSuffix = '') {
31
44
  selection,
32
45
  context,
33
46
  app,
34
- open: () => client.apps.open({ ...selection, app, noRecord: true }),
47
+ open: async () => {
48
+ if (platform === 'ios' && env.METAMASK_RECIPE_MOBILE_OPAQUE_RUNTIME !== '1') {
49
+ await hideAccessibilityMaskingHud({ node: input.node }, input.context);
50
+ }
51
+ await transport.open();
52
+ },
35
53
  execute: (action, node) => transport.execute(action, node, context),
36
54
  snapshot: () => client.capture.snapshot({ ...selection, app, interactiveOnly: false, forceFull: true }),
37
55
  };
38
56
  }
39
57
 
40
58
  export async function closeNativeSession(session) {
41
- try {
42
- await session.transport.close();
43
- } catch (error) {
44
- if (!/No active session|SESSION_NOT_FOUND/iu.test(String(error?.message ?? error))) throw error;
45
- }
46
- try {
47
- await session.client.sessions.close();
48
- } catch (error) {
49
- if (!/No active session|SESSION_NOT_FOUND/iu.test(String(error?.message ?? error))) throw error;
50
- }
59
+ await session.transport.close();
51
60
  }
52
61
 
53
62
  export function nativeNode(snapshot, identifier) {
@@ -45,7 +45,7 @@ export async function observeNativeUi(payload, context) {
45
45
  }
46
46
  }
47
47
 
48
- async function hideAccessibilityMaskingHud(payload, context) {
48
+ export async function hideAccessibilityMaskingHud(payload, context) {
49
49
  try {
50
50
  await bridgeCommand(
51
51
  {
@@ -429,7 +429,7 @@ function parseAndroidHierarchy(output) {
429
429
  isActionableRole(role) ||
430
430
  attributes.clickable === 'true' ||
431
431
  attributes['long-clickable'] === 'true';
432
- if (isActionable) {
432
+ if (isActionable || item.test_id) {
433
433
  if (isVisible) {
434
434
  visible.push(item);
435
435
  } else {
@@ -12,7 +12,7 @@ import {
12
12
  const PAGE_ROUTES = {
13
13
  home: { route: 'WalletView', params: {} },
14
14
  perps: { route: 'PerpsMarketListView', params: {} },
15
- 'perps-market-list': { route: 'PerpsMarketListView', params: {} },
15
+ 'perps-market-list': { route: 'PerpsTrendingView', params: {} },
16
16
  swap: { route: 'BridgeView', params: {} },
17
17
  };
18
18