@deeeed/metamask-harness 0.37.0 → 0.38.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 (113) hide show
  1. package/CHANGELOG.md +27 -0
  2. package/adapters/extension/artifact-runtime-state.cjs +13 -3
  3. package/adapters/extension/inject.mjs +1 -0
  4. package/adapters/extension/launch-browser.cjs +2 -0
  5. package/adapters/extension/live.sh +1 -1
  6. package/adapters/extension/readiness.mjs +1 -1
  7. package/adapters/manifest.json +8 -0
  8. package/dist/adapters/extension/runtime-decision.js +4 -1
  9. package/dist/adapters/extension/runtime.js +42 -3
  10. package/dist/adapters/mobile/provision.js +38 -3
  11. package/dist/adapters.js +68 -3
  12. package/dist/app-lifecycle.js +4 -3
  13. package/dist/commands/call.js +4 -1
  14. package/dist/commands/launch/extension.js +21 -5
  15. package/dist/commands/launch/index.js +3 -0
  16. package/dist/commands/run-engine.js +40 -29
  17. package/dist/commands/run.js +1 -1
  18. package/dist/recipe-security.js +40 -0
  19. package/dist/runner.js +3 -0
  20. package/docs/RELEASE-QA-CAPABILITY-MAP.md +116 -141
  21. package/library/actions/extension/assets/finish_send.mjs +128 -0
  22. package/library/actions/extension/assets/import_custom_token.mjs +227 -0
  23. package/library/actions/extension/assets/prepare_send.mjs +53 -0
  24. package/library/actions/extension/assets/read_details.mjs +57 -0
  25. package/library/actions/extension/assets/read_visible_state.mjs +50 -0
  26. package/library/actions/extension/assets/review_send.mjs +23 -0
  27. package/library/actions/extension/assets/select_network_scope.mjs +30 -0
  28. package/library/actions/extension/assets/send.mjs +104 -0
  29. package/library/actions/extension/assets/set_custom_gas.mjs +56 -0
  30. package/library/actions/extension/assets/set_token_visibility.mjs +129 -0
  31. package/library/actions/extension/assets/verify_sorting.mjs +89 -0
  32. package/library/actions/extension/networks/add_chainlist.mjs +94 -0
  33. package/library/actions/extension/networks/add_custom.mjs +123 -0
  34. package/library/actions/extension/networks/custom_network.mjs +103 -0
  35. package/library/actions/extension/networks/read_visible_state.mjs +50 -0
  36. package/library/actions/extension/networks/remove_custom.mjs +68 -0
  37. package/library/actions/extension/perps/close_orders.mjs +1 -0
  38. package/library/actions/extension/perps/close_visible_position.mjs +133 -0
  39. package/library/actions/extension/perps/compare_provider_market.mjs +109 -0
  40. package/library/actions/extension/perps/edit_margin.mjs +82 -0
  41. package/library/actions/extension/perps/open_balance_action.mjs +58 -0
  42. package/library/actions/extension/perps/open_position_action.mjs +80 -0
  43. package/library/actions/extension/perps/perps.mjs +194 -2
  44. package/library/actions/extension/perps/place_order.mjs +5 -2
  45. package/library/actions/extension/perps/read_eligibility.mjs +34 -0
  46. package/library/actions/extension/perps/read_funds_confirmation.mjs +69 -0
  47. package/library/actions/extension/perps/read_visible_state.mjs +98 -0
  48. package/library/actions/extension/perps/select_activity_filter.mjs +44 -0
  49. package/library/actions/extension/perps/select_market_filter.mjs +71 -0
  50. package/library/actions/extension/perps/set_market_favorite.mjs +47 -0
  51. package/library/actions/extension/perps/update_position_tpsl.mjs +106 -0
  52. package/library/actions/extension/platform/cdp.mjs +104 -1
  53. package/library/actions/extension/settings/set_basic_functionality.mjs +58 -0
  54. package/library/actions/extension/swap_bridge/read_visible_state.mjs +71 -0
  55. package/library/actions/extension/swap_bridge/select_assets.mjs +103 -0
  56. package/library/actions/extension/swap_bridge/set_amount.mjs +36 -0
  57. package/library/actions/extension/swap_bridge/set_max_amount.mjs +58 -0
  58. package/library/actions/extension/swap_bridge/set_slippage.mjs +121 -0
  59. package/library/actions/extension/swap_bridge/submit_transaction.mjs +363 -0
  60. package/library/actions/extension/ui/activate_and_observe.mjs +96 -0
  61. package/library/actions/extension/ui/navigate.mjs +264 -8
  62. package/library/actions/extension/wallet/ensure_unlocked.mjs +2 -6
  63. package/library/actions/extension/wallet/lock.mjs +35 -0
  64. package/library/actions/extension/wallet/select_account.mjs +71 -7
  65. package/library/actions/mobile/assets/read_visible_state.mjs +37 -0
  66. package/library/actions/mobile/perps/read_visible_state.mjs +31 -0
  67. package/library/actions/mobile/platform/native-session-name.mjs +5 -0
  68. package/library/actions/mobile/platform/observe-ui.mjs +5 -3
  69. package/library/actions/mobile/swap_bridge/native-session.mjs +45 -0
  70. package/library/actions/mobile/swap_bridge/read_visible_state.mjs +31 -0
  71. package/library/actions/mobile/swap_bridge/select_assets.mjs +94 -0
  72. package/library/actions/mobile/swap_bridge/set_amount.mjs +73 -0
  73. package/library/actions/mobile/ui/native-navigation.mjs +45 -5
  74. package/library/actions/mobile/ui/navigate.mjs +3 -1
  75. package/library/actions/mobile/wallet/ensure_unlocked.mjs +17 -0
  76. package/library/actions/mobile/wallet/native-ui.mjs +21 -2
  77. package/library/actions/shared/assets/visible-state.mjs +176 -0
  78. package/library/actions/shared/networks/visible-state.mjs +70 -0
  79. package/library/actions/shared/perps/visible-state.mjs +354 -0
  80. package/library/actions/shared/swap-bridge/transaction.mjs +54 -0
  81. package/library/actions/shared/swap-bridge/visible-state.mjs +97 -0
  82. package/library/manifests/extension.action-manifest.json +1255 -189
  83. package/library/manifests/mobile.action-manifest.json +219 -1
  84. package/library/recipes/assets/release-custom-gas-send.recipe.json +88 -0
  85. package/library/recipes/assets/release-custom-network-token-persistence.recipe.json +106 -0
  86. package/library/recipes/assets/release-native-max-cancel.recipe.json +70 -0
  87. package/library/recipes/assets/release-native-max-send.recipe.json +78 -0
  88. package/library/recipes/assets/release-own-account-send.recipe.json +80 -0
  89. package/library/recipes/assets/release-token-address-import.recipe.json +47 -0
  90. package/library/recipes/assets/release-token-details.recipe.json +48 -0
  91. package/library/recipes/assets/release-token-hide-readd.recipe.json +55 -0
  92. package/library/recipes/assets/release-token-list.recipe.json +75 -0
  93. package/library/recipes/assets/release-token-persistence.recipe.json +73 -0
  94. package/library/recipes/assets/release-token-search-import.recipe.json +40 -0
  95. package/library/recipes/assets/release-token-sorting.recipe.json +29 -0
  96. package/library/recipes/extension/networks/release-balance-stability.recipe.json +51 -0
  97. package/library/recipes/extension/perps/release-live-limit-order.recipe.json +104 -0
  98. package/library/recipes/extension/perps/release-order-entry.recipe.json +155 -0
  99. package/library/recipes/extension/perps/release-order-validation.recipe.json +220 -0
  100. package/library/recipes/perps/release-activity-filters.recipe.json +51 -0
  101. package/library/recipes/perps/release-funds-flows.recipe.json +76 -0
  102. package/library/recipes/perps/release-live-market-order.recipe.json +122 -0
  103. package/library/recipes/perps/release-market-details.recipe.json +83 -0
  104. package/library/recipes/perps/release-market-search.recipe.json +79 -0
  105. package/library/recipes/perps/release-watchlist.recipe.json +99 -0
  106. package/library/recipes/swap-bridge/release-custom-slippage.recipe.json +90 -0
  107. package/library/recipes/swap-bridge/release-live-max-swap.recipe.json +99 -0
  108. package/library/recipes/swap-bridge/release-live-swap.recipe.json +101 -0
  109. package/library/recipes/swap-bridge/release-max-quote.recipe.json +72 -0
  110. package/library/recipes/swap-bridge/release-quote.recipe.json +72 -0
  111. package/library/recipes/swap-bridge/release-surface.recipe.json +43 -0
  112. package/package.json +6 -5
  113. package/scripts/validate-release-report.mjs +319 -0
@@ -12,8 +12,15 @@ const BACK_CONTROLS = [
12
12
  'perps-market-detail-back-button',
13
13
  'perps-activity-back-button',
14
14
  'perps-withdraw-back-button',
15
+ 'wallet-initiated-header-back-button',
16
+ 'transaction-details-back-button',
17
+ 'asset__back',
18
+ 'token-management-header-back-button',
19
+ 'custom-token-import-back-button',
20
+ 'networks-page-form-back-button',
21
+ 'page-header-back-button',
15
22
  'back-button',
16
- ].map(dataTestId);
23
+ ].map(dataTestId).concat('button[aria-label="Back"]');
17
24
 
18
25
  function text(value) {
19
26
  return typeof value === 'string' && value.length > 0 ? value : undefined;
@@ -22,13 +29,37 @@ function text(value) {
22
29
  function pageIntent(node) {
23
30
  const page = text(node?.page);
24
31
  if (!page) return undefined;
25
- if (page === 'home' || page === 'perps') return { page };
32
+ if (page === 'back' || page === 'home' || page === 'perps' || page === 'swap') return { page };
33
+ if (page === 'perps-market-list' || page === 'perps-activity') return { page };
34
+ if (page === 'network') {
35
+ const network = text(node.network);
36
+ const chainId = Number(node.chain_id);
37
+ if (!network || !Number.isInteger(chainId) || chainId <= 0) {
38
+ throw new Error('extension ui.navigate page=network requires network and a positive integer chain_id.');
39
+ }
40
+ return { page, network, chainId, addIfMissing: node.add_if_missing !== false };
41
+ }
26
42
  if (page === 'perps-market') {
27
43
  const market = text(node.market) ?? text(node.symbol);
28
44
  if (!market) throw new Error('extension ui.navigate page=perps-market requires market or symbol.');
29
45
  return { page, market };
30
46
  }
31
- throw new Error('extension ui.navigate supported page intents: home, perps, perps-market.');
47
+ throw new Error('extension ui.navigate supported page intents: back, home, network, perps, perps-market-list, perps-market, perps-activity, swap.');
48
+ }
49
+
50
+ async function goBack(page, timeoutMs) {
51
+ const before = await currentHref(page);
52
+ const history = await page.session.call('Page.getNavigationHistory');
53
+ const entry = history.entries?.[history.currentIndex - 1];
54
+ if (!entry) throw new Error('extension ui.navigate page=back has no previous browser history entry.');
55
+ await page.session.call('Page.navigateToHistoryEntry', { entryId: entry.id });
56
+ const deadline = Date.now() + timeoutMs;
57
+ while (Date.now() < deadline) {
58
+ const href = await currentHref(page);
59
+ if (href !== before) return { method: 'browser-history', href, previousHref: before };
60
+ await pauseForUi(page);
61
+ }
62
+ throw new Error(`extension ui.navigate page=back did not leave ${JSON.stringify(before)}.`);
32
63
  }
33
64
 
34
65
  async function hasVisibleSelector(page, selector) {
@@ -59,6 +90,12 @@ async function currentHref(page) {
59
90
  return page.evaluate('location.href');
60
91
  }
61
92
 
93
+ function isHomeHref(href) {
94
+ const hash = href.includes('#') ? href.slice(href.indexOf('#')) : '';
95
+ const route = hash.split('?')[0];
96
+ return route === '' || route === '#' || route === '#/';
97
+ }
98
+
62
99
  async function activateSemanticControl(page, selector) {
63
100
  return {
64
101
  activation: 'trusted-pointer',
@@ -68,6 +105,7 @@ async function activateSemanticControl(page, selector) {
68
105
 
69
106
  export async function openHome(page, timeoutMs) {
70
107
  const home = dataTestId('bottom-nav-home');
108
+ const drawerClose = dataTestId('drawer-close-button');
71
109
  const selectedHome = `${home}[aria-current="page"]`;
72
110
  const legacyHome = dataTestId('account-overview__asset-tab');
73
111
  const selectedLegacyHome = `${legacyHome}[aria-selected="true"]`;
@@ -76,13 +114,20 @@ export async function openHome(page, timeoutMs) {
76
114
  let pendingActivation;
77
115
 
78
116
  while (Date.now() < deadline) {
117
+ if (await hasVisibleSelector(page, drawerClose)) {
118
+ await activateSemanticControl(page, drawerClose);
119
+ steps.push(drawerClose);
120
+ await pauseForUi(page);
121
+ continue;
122
+ }
123
+ const href = await currentHref(page);
79
124
  if (
80
- (await hasVisibleSelector(page, selectedHome)) ||
81
- (await hasVisibleSelector(page, selectedLegacyHome))
125
+ isHomeHref(href) &&
126
+ ((await hasVisibleSelector(page, selectedHome)) ||
127
+ (await hasVisibleSelector(page, selectedLegacyHome)))
82
128
  ) {
83
- return { method: 'visible-ui', href: await currentHref(page), steps };
129
+ return { method: 'visible-ui', href, steps };
84
130
  }
85
- const href = await currentHref(page);
86
131
  if (pendingActivation && pendingActivation.href !== href) {
87
132
  pendingActivation = undefined;
88
133
  await pauseForUi(page);
@@ -130,12 +175,19 @@ export async function openPerpsHome(page, timeoutMs) {
130
175
  const bottomNav = dataTestId('bottom-nav-perps');
131
176
  const legacyTab = dataTestId('account-overview__perps-tab');
132
177
  const perpsHome = dataTestId('perps-home-page');
178
+ const tutorialSkip = dataTestId('perps-tutorial-skip-button');
133
179
  const steps = [];
134
180
  const deadline = Date.now() + timeoutMs;
135
181
  let pendingActivation;
136
182
  let perpsActivation;
137
183
 
138
184
  while (Date.now() < deadline) {
185
+ if (await hasVisibleSelector(page, tutorialSkip)) {
186
+ await activateSemanticControl(page, tutorialSkip);
187
+ steps.push(tutorialSkip);
188
+ await pauseForUi(page);
189
+ continue;
190
+ }
139
191
  if (await hasVisibleSelector(page, perpsHome)) {
140
192
  return {
141
193
  ...(perpsActivation ? { navigation: perpsActivation, selector: bottomNav } : {}),
@@ -207,8 +259,23 @@ export async function openPerpsHome(page, timeoutMs) {
207
259
 
208
260
  export async function openPerpsMarket(page, market, timeoutMs) {
209
261
  const deadline = Date.now() + timeoutMs;
210
- const home = await openPerpsHome(page, Math.max(1, deadline - Date.now()));
211
262
  const normalizedMarket = normalizeMarketSymbol(market.trim());
263
+ if (await hasVisibleSelector(page, dataTestId('market-list-view'))) {
264
+ const marketRow = dataTestId(`market-row-${normalizedMarket.replace(':', '-')}`);
265
+ await page.waitForSelector(marketRow, { timeoutMs: Math.max(1, deadline - Date.now()) });
266
+ const navigation = await activateSemanticControl(page, marketRow);
267
+ await page.waitForSelector(dataTestId('perps-market-detail-page'), {
268
+ timeoutMs: Math.max(1, deadline - Date.now()),
269
+ });
270
+ return {
271
+ navigation,
272
+ method: 'visible-ui',
273
+ href: await currentHref(page),
274
+ selector: marketRow,
275
+ steps: [marketRow],
276
+ };
277
+ }
278
+ const home = await openPerpsHome(page, Math.max(1, deadline - Date.now()));
212
279
  const selectors = [
213
280
  dataTestId(`perps-watchlist-${normalizedMarket}`),
214
281
  dataTestId(`explore-markets-${normalizedMarket.replaceAll(':', '-')}`),
@@ -239,11 +306,178 @@ export async function openPerpsMarket(page, market, timeoutMs) {
239
306
  };
240
307
  }
241
308
 
309
+ export async function openPerpsActivity(page, timeoutMs) {
310
+ if (await hasVisibleSelector(page, dataTestId('perps-activity-page'))) {
311
+ return { method: 'visible-ui', href: await currentHref(page), steps: [] };
312
+ }
313
+ const deadline = Date.now() + timeoutMs;
314
+ const home = await openPerpsHome(page, Math.max(1, deadline - Date.now()));
315
+ const activity = dataTestId('perps-recent-activity-see-all');
316
+ await page.waitForSelector(activity, { timeoutMs: Math.max(1, deadline - Date.now()) });
317
+ const navigation = await activateSemanticControl(page, activity);
318
+ await page.waitForSelector(dataTestId('perps-activity-page'), {
319
+ timeoutMs: Math.max(1, deadline - Date.now()),
320
+ });
321
+ return {
322
+ navigation,
323
+ method: 'visible-ui',
324
+ href: await currentHref(page),
325
+ selector: activity,
326
+ steps: [...(home.steps ?? []), activity],
327
+ };
328
+ }
329
+
330
+ export async function openPerpsMarketList(page, timeoutMs) {
331
+ const marketList = dataTestId('market-list-view');
332
+ if (await hasVisibleSelector(page, marketList)) {
333
+ return { method: 'visible-ui', href: await currentHref(page), steps: [] };
334
+ }
335
+ const deadline = Date.now() + timeoutMs;
336
+ const detailListButton = dataTestId('perps-market-detail-market-list-button');
337
+ if (await hasVisibleSelector(page, detailListButton)) {
338
+ const navigation = await activateSemanticControl(page, detailListButton);
339
+ await page.waitForSelector(marketList, { timeoutMs: Math.max(1, deadline - Date.now()) });
340
+ return {
341
+ navigation,
342
+ method: 'visible-ui',
343
+ href: await currentHref(page),
344
+ selector: detailListButton,
345
+ steps: [detailListButton],
346
+ };
347
+ }
348
+ const home = await openPerpsHome(page, Math.max(1, deadline - Date.now()));
349
+ const explore = dataTestId('perps-explore-markets-row');
350
+ await page.waitForSelector(explore, { timeoutMs: Math.max(1, deadline - Date.now()) });
351
+ const navigation = await activateSemanticControl(page, explore);
352
+ await page.waitForSelector(marketList, { timeoutMs: Math.max(1, deadline - Date.now()) });
353
+ return {
354
+ navigation,
355
+ method: 'visible-ui',
356
+ href: await currentHref(page),
357
+ selector: explore,
358
+ steps: [...home.steps, explore],
359
+ };
360
+ }
361
+
362
+ export async function openNetwork(page, network, chainId, addIfMissing, timeoutMs) {
363
+ const deadline = Date.now() + timeoutMs;
364
+ const hexChainId = `0x${chainId.toString(16)}`;
365
+ const installed = dataTestId(`home-network-filter-network-${hexChainId}`);
366
+ const custom = dataTestId(`home-network-filter-custom-${hexChainId}`);
367
+ const additional = dataTestId(`home-network-filter-additional-${hexChainId}`);
368
+ const manage = dataTestId('home-network-filter-manage-networks');
369
+ const sort = dataTestId('sort-by-networks');
370
+ const assets = dataTestId('account-overview__asset-tab');
371
+ const steps = [];
372
+ await openHome(page, Math.max(1, deadline - Date.now()));
373
+ if (!(await hasVisibleSelector(page, `${assets}[aria-selected="true"]`))) {
374
+ await activateSemanticControl(page, assets);
375
+ steps.push(assets);
376
+ if (!(await waitForFirstVisible(page, [`${assets}[aria-selected="true"]`], deadline))) {
377
+ throw new Error('Token assets did not become selected before network navigation.');
378
+ }
379
+ }
380
+ await activateSemanticControl(page, sort);
381
+ steps.push(sort);
382
+ let choice = await waitForFirstVisible(page, [installed, custom, additional, manage], deadline);
383
+ if (!choice) throw new Error(`Network filter did not show ${network} (${chainId}) before timeout.`);
384
+ if (choice !== manage) {
385
+ await activateSemanticControl(page, choice);
386
+ steps.push(choice);
387
+ } else {
388
+ if (!addIfMissing) {
389
+ throw new Error(`Network ${network} (${chainId}) is not added to the wallet.`);
390
+ }
391
+ await activateSemanticControl(page, manage);
392
+ steps.push(manage);
393
+ const listItem = dataTestId(`network-list-item-eip155:${chainId}`);
394
+ if (!await hasVisibleSelector(page, listItem)) {
395
+ const popular = `${dataTestId(`popular-network-${hexChainId}`)} ${dataTestId('test-add-button')}`;
396
+ await page.waitForSelector(popular, { timeoutMs: Math.max(1, deadline - Date.now()) });
397
+ await activateSemanticControl(page, popular);
398
+ steps.push(popular);
399
+ await page.waitForSelector(listItem, { timeoutMs: Math.max(1, deadline - Date.now()) });
400
+ }
401
+ const back = dataTestId('page-header-back-button');
402
+ await activateSemanticControl(page, back);
403
+ steps.push(back);
404
+ await page.waitForSelector(sort, { timeoutMs: Math.max(1, deadline - Date.now()) });
405
+ await activateSemanticControl(page, sort);
406
+ steps.push(sort);
407
+ choice = await waitForFirstVisible(page, [installed, custom, additional], deadline);
408
+ if (!choice) throw new Error(`Added network ${network} (${chainId}) did not appear in the wallet filter.`);
409
+ await activateSemanticControl(page, choice);
410
+ steps.push(choice);
411
+ }
412
+ while (Date.now() < deadline) {
413
+ const label = await page.evaluate(`String(document.querySelector(${JSON.stringify(sort)})?.innerText || '')`);
414
+ if (label.toLowerCase().includes(network.toLowerCase())) {
415
+ return { method: 'visible-ui', href: await currentHref(page), network, chainId, steps };
416
+ }
417
+ await pauseForUi(page);
418
+ }
419
+ throw new Error(`Network ${network} (${chainId}) did not become the selected wallet filter.`);
420
+ }
421
+
422
+ export async function openSwap(page, timeoutMs) {
423
+ const sourceAmount = dataTestId('from-amount');
424
+ const sourceToken = dataTestId('bridge-source-button');
425
+ const destinationToken = dataTestId('bridge-destination-button');
426
+ const bottomNav = dataTestId('bottom-nav-swaps');
427
+ const deadline = Date.now() + timeoutMs;
428
+ if (
429
+ await hasVisibleSelector(page, sourceAmount) &&
430
+ await hasVisibleSelector(page, sourceToken) &&
431
+ await hasVisibleSelector(page, destinationToken)
432
+ ) {
433
+ return { method: 'visible-ui', href: await currentHref(page), steps: [] };
434
+ }
435
+ const home = await openHome(page, Math.max(1, deadline - Date.now()));
436
+ let navigation;
437
+ let lastActivationAt = 0;
438
+ let observedHref = await currentHref(page);
439
+ while (Date.now() < deadline) {
440
+ if (
441
+ await hasVisibleSelector(page, sourceAmount) &&
442
+ await hasVisibleSelector(page, sourceToken) &&
443
+ await hasVisibleSelector(page, destinationToken)
444
+ ) break;
445
+ const href = await currentHref(page);
446
+ if (href !== observedHref) {
447
+ observedHref = href;
448
+ lastActivationAt = Date.now();
449
+ }
450
+ if (await hasVisibleSelector(page, bottomNav) && (!navigation || Date.now() - lastActivationAt >= 1000)) {
451
+ navigation = await activateSemanticControl(page, bottomNav);
452
+ lastActivationAt = Date.now();
453
+ }
454
+ await pauseForUi(page);
455
+ }
456
+ if (
457
+ !(await hasVisibleSelector(page, sourceAmount)) ||
458
+ !(await hasVisibleSelector(page, sourceToken)) ||
459
+ !(await hasVisibleSelector(page, destinationToken))
460
+ ) {
461
+ throw new Error('Swap inputs did not appear after visible Swap navigation.');
462
+ }
463
+ return {
464
+ navigation,
465
+ method: 'visible-ui',
466
+ href: await currentHref(page),
467
+ selector: bottomNav,
468
+ steps: [...home.steps, bottomNav],
469
+ };
470
+ }
471
+
242
472
  export async function navigateExtensionUi(input) {
243
473
  return withExtensionPage(input, async (page) => {
244
474
  const intent = pageIntent(input.node);
245
475
  if (intent) {
246
476
  const timeoutMs = Number(input.node?.timeout_ms ?? 20000);
477
+ if (intent.page === 'back') {
478
+ const result = await goBack(page, timeoutMs);
479
+ return { action: input.action, ...intent, ...result, proofPath: 'ui-navigation' };
480
+ }
247
481
  if (intent.page === 'home') {
248
482
  const result = await openHome(page, timeoutMs);
249
483
  return { action: input.action, ...intent, ...result, proofPath: 'ui-navigation' };
@@ -252,6 +486,28 @@ export async function navigateExtensionUi(input) {
252
486
  const result = await openPerpsHome(page, timeoutMs);
253
487
  return { action: input.action, ...intent, ...result, proofPath: 'ui-navigation' };
254
488
  }
489
+ if (intent.page === 'perps-market-list') {
490
+ const result = await openPerpsMarketList(page, timeoutMs);
491
+ return { action: input.action, ...intent, ...result, proofPath: 'ui-navigation' };
492
+ }
493
+ if (intent.page === 'perps-activity') {
494
+ const result = await openPerpsActivity(page, timeoutMs);
495
+ return { action: input.action, ...intent, ...result, proofPath: 'ui-navigation' };
496
+ }
497
+ if (intent.page === 'network') {
498
+ const result = await openNetwork(
499
+ page,
500
+ intent.network,
501
+ intent.chainId,
502
+ intent.addIfMissing,
503
+ timeoutMs,
504
+ );
505
+ return { action: input.action, ...intent, ...result, proofPath: 'ui-navigation' };
506
+ }
507
+ if (intent.page === 'swap') {
508
+ const result = await openSwap(page, timeoutMs);
509
+ return { action: input.action, ...intent, ...result, proofPath: 'ui-navigation' };
510
+ }
255
511
  const result = await openPerpsMarket(page, intent.market, timeoutMs);
256
512
  return { action: input.action, ...intent, ...result, proofPath: 'ui-navigation' };
257
513
  }
@@ -1,5 +1,5 @@
1
1
  import { readFile } from 'node:fs/promises';
2
- import { runAdapter, withExtensionPage } from '../platform/cdp.mjs';
2
+ import { dataTestId, runAdapter, withExtensionPage } from '../platform/cdp.mjs';
3
3
  import { walletFixturePath } from '../../harness-exports.mjs';
4
4
 
5
5
  async function fixturePassword(projectRoot) {
@@ -32,11 +32,7 @@ runAdapter((input) => withExtensionPage(input, async (page) => {
32
32
 
33
33
  const password = input.node?.password ?? await fixturePassword(input.context.projectRoot);
34
34
  await page.setInput('input[type="password"]', String(password));
35
- await page.evaluate(`(() => {
36
- const button = document.querySelector('button[type="submit"]') || Array.from(document.querySelectorAll('button')).find((candidate) => /unlock/i.test(candidate.innerText || candidate.textContent || ''));
37
- if (!button) throw new Error('Unlock button not found.');
38
- button.click();
39
- })()`);
35
+ await page.click(dataTestId('unlock-submit'));
40
36
  await page.waitForExpression(`!document.querySelector('input[type="password"]')`, { timeoutMs: Number(input.node?.timeout_ms ?? 15000) });
41
37
  return { action: input.action, unlocked: true, redacted: true, proofPath: 'extension-password-unlock' };
42
38
  }));
@@ -0,0 +1,35 @@
1
+ import { dataTestId, runAdapter, withExtensionPage } from '../platform/cdp.mjs';
2
+
3
+ const MENU = dataTestId('account-options-menu-button');
4
+ const LOCK = dataTestId('global-menu-lock');
5
+ const UNLOCK_PAGE = dataTestId('unlock-page');
6
+
7
+ async function visible(page, selector) {
8
+ return page.evaluate(`(() => {
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
+ }
16
+
17
+ runAdapter(async (input) => withExtensionPage(input, async (page) => {
18
+ if (!(await visible(page, LOCK))) await page.click(MENU);
19
+ const deadline = Date.now() + Number(input.node?.timeout_ms ?? 15_000);
20
+ while (!(await visible(page, LOCK))) {
21
+ if (Date.now() >= deadline) throw new Error('Wallet lock control did not become visible.');
22
+ await page.evaluate('new Promise((resolve) => setTimeout(resolve, 100))');
23
+ }
24
+ await page.click(LOCK);
25
+ while (!(await visible(page, UNLOCK_PAGE))) {
26
+ if (Date.now() >= deadline) throw new Error('Wallet did not reach the unlock page after locking.');
27
+ await page.evaluate('new Promise((resolve) => setTimeout(resolve, 100))');
28
+ }
29
+ return {
30
+ action: input.action,
31
+ locked: true,
32
+ route: await page.evaluate('location.hash'),
33
+ proofPath: 'trusted-pointer-visible-ui',
34
+ };
35
+ }));
@@ -1,5 +1,9 @@
1
1
  import { runAdapter, withExtensionPage } from '../platform/cdp.mjs';
2
- import { selectWalletAccountExpression } from './state.mjs';
2
+ import {
3
+ listWalletAccountsExpression,
4
+ readWalletStateExpression,
5
+ selectWalletAccountExpression,
6
+ } from './state.mjs';
3
7
 
4
8
  function normalize(value) {
5
9
  return String(value ?? '').toLowerCase();
@@ -12,10 +16,70 @@ runAdapter((input) => withExtensionPage(input, async (page) => {
12
16
  if (!requestedAddress && !requestedId && !requestedName) {
13
17
  throw new Error('metamask.wallet.select_account extension live adapter requires node.address, node.id, or node.name.');
14
18
  }
15
- const selected = await page.evaluate(selectWalletAccountExpression({
16
- address: requestedAddress,
17
- id: requestedId,
18
- name: requestedName,
19
- }), { awaitPromise: true });
20
- return { action: input.action, selected, proofPath: 'extension-account-selection' };
19
+ try {
20
+ const selected = await page.evaluate(selectWalletAccountExpression({
21
+ address: requestedAddress,
22
+ id: requestedId,
23
+ name: requestedName,
24
+ }), { awaitPromise: true });
25
+ return { action: input.action, selected, proofPath: 'extension-background-account-selection' };
26
+ } catch (error) {
27
+ if (!String(error?.message ?? error).includes('submitRequestToBackground is unavailable')) throw error;
28
+ }
29
+
30
+ let searchAddress = requestedAddress;
31
+ if (!searchAddress) {
32
+ const listed = await page.evaluate(listWalletAccountsExpression('all'), { awaitPromise: true });
33
+ const account = listed.accounts.find((entry) =>
34
+ (requestedId && entry.id === requestedId) ||
35
+ (requestedName && entry.name === requestedName));
36
+ searchAddress = normalize(account?.address);
37
+ }
38
+ if (!searchAddress) throw new Error('Requested account does not have a searchable EVM address.');
39
+
40
+ await page.navigateHash('#/account-list');
41
+ const searchSelector = 'input[data-testid="multichain-account-list-search"], [data-testid="multichain-account-list-search"] input';
42
+ await page.waitForSelector(searchSelector);
43
+ await page.setInput(searchSelector, searchAddress);
44
+
45
+ const rowDeadline = Date.now() + 15_000;
46
+ let row;
47
+ while (Date.now() <= rowDeadline) {
48
+ row = await page.evaluate(`(() => {
49
+ const rows = [...document.querySelectorAll('.multichain-account-cell[data-testid^="multichain-account-cell-"]')]
50
+ .filter((element) => {
51
+ const style = getComputedStyle(element);
52
+ const rect = element.getBoundingClientRect();
53
+ return style.display !== 'none' && style.visibility !== 'hidden' && rect.width > 0 && rect.height > 0;
54
+ });
55
+ return {
56
+ count: rows.length,
57
+ testId: rows.length === 1 ? rows[0].getAttribute('data-testid') : null,
58
+ label: rows.length === 1 ? String(rows[0].innerText || '').replace(/\\s+/gu, ' ').trim().slice(0, 500) : null,
59
+ };
60
+ })()`);
61
+ if (row.count === 1 && row.testId) break;
62
+ await new Promise((resolve) => setTimeout(resolve, 100));
63
+ }
64
+ if (row?.count !== 1 || !row.testId) {
65
+ throw new Error(`Expected one visible account for the requested address, observed ${row?.count ?? 0}.`);
66
+ }
67
+ await page.click(`[data-testid=${JSON.stringify(row.testId)}]`);
68
+
69
+ const selectedDeadline = Date.now() + 15_000;
70
+ let selected;
71
+ while (Date.now() <= selectedDeadline) {
72
+ const state = await page.evaluate(readWalletStateExpression(), { awaitPromise: true });
73
+ selected = state.selectedAccount;
74
+ if (normalize(selected?.address) === searchAddress) {
75
+ return {
76
+ action: input.action,
77
+ selected,
78
+ visibleAccount: row,
79
+ proofPath: 'trusted-pointer-visible-ui',
80
+ };
81
+ }
82
+ await new Promise((resolve) => setTimeout(resolve, 100));
83
+ }
84
+ throw new Error(`Visible account selection did not select ${searchAddress}.`);
21
85
  }));
@@ -0,0 +1,37 @@
1
+ import { runAdapter } from '../platform/bridge.mjs';
2
+ import { observeNativeUi } from '../platform/observe-ui.mjs';
3
+ import {
4
+ normalizeVisibleAssetsState,
5
+ resolveVisibleAssetsOptions,
6
+ } from '../../shared/assets/visible-state.mjs';
7
+
8
+ runAdapter(async (input) => {
9
+ const options = resolveVisibleAssetsOptions(input.node);
10
+ const observed = await observeNativeUi(
11
+ { refs: ['ui.screen', 'ui.visible'], node: input.node },
12
+ input.context,
13
+ );
14
+ const screen = observed.observations?.['ui.screen'];
15
+ const visible = observed.observations?.['ui.visible'];
16
+ if (!screen || !visible) {
17
+ const warning = observed.warnings?.map((entry) => entry.message).filter(Boolean).join('; ');
18
+ throw new Error(`Mobile visible Assets state is unavailable${warning ? `: ${warning}` : '.'}`);
19
+ }
20
+ const items = [...(visible.items ?? []), ...(visible.hidden_or_offscreen ?? [])]
21
+ .filter((item) => String(item.test_id ?? '').startsWith('asset-'))
22
+ .map((item) => ({
23
+ testId: item.test_id,
24
+ label: item.label,
25
+ symbol: String(item.test_id).slice('asset-'.length),
26
+ }));
27
+ return {
28
+ action: input.action,
29
+ ...normalizeVisibleAssetsState({
30
+ route: screen.route,
31
+ title: screen.title,
32
+ items,
33
+ truncated: visible.truncated,
34
+ }, options, 'mobile'),
35
+ proofPath: 'native-accessibility',
36
+ };
37
+ });
@@ -0,0 +1,31 @@
1
+ import { runAdapter } from '../platform/bridge.mjs';
2
+ import { observeNativeUi } from '../platform/observe-ui.mjs';
3
+ import {
4
+ normalizeVisiblePerpsState,
5
+ resolveVisibleStateOptions,
6
+ } from '../../shared/perps/visible-state.mjs';
7
+
8
+ runAdapter(async (input) => {
9
+ const options = resolveVisibleStateOptions(input.node);
10
+ const observed = await observeNativeUi(
11
+ { refs: ['ui.screen', 'ui.visible'], node: input.node },
12
+ input.context,
13
+ );
14
+ const screen = observed.observations?.['ui.screen'];
15
+ const visible = observed.observations?.['ui.visible'];
16
+ if (!screen || !visible) {
17
+ const warning = observed.warnings?.map((entry) => entry.message).filter(Boolean).join('; ');
18
+ throw new Error(`Mobile visible Perps state is unavailable${warning ? `: ${warning}` : '.'}`);
19
+ }
20
+ return {
21
+ action: input.action,
22
+ ...normalizeVisiblePerpsState({
23
+ route: screen.route,
24
+ title: screen.title,
25
+ items: visible.items,
26
+ offscreenItems: visible.hidden_or_offscreen,
27
+ truncated: visible.truncated,
28
+ }, options, 'mobile'),
29
+ proofPath: 'native-accessibility',
30
+ };
31
+ });
@@ -0,0 +1,5 @@
1
+ export function nativeSessionName(env, device, app = 'io.metamask') {
2
+ const configured = String(env.METAMASK_RECIPE_MOBILE_SESSION ?? '').trim();
3
+ const source = configured || `mm-harness-${app}-${device}`;
4
+ return source.replace(/[^A-Za-z0-9._-]/gu, '-').slice(0, 120);
5
+ }
@@ -3,6 +3,7 @@ import { promisify } from 'node:util';
3
3
  import { createAgentDeviceClient } from 'agent-device';
4
4
 
5
5
  import { bridgeCommand } from './bridge.mjs';
6
+ import { nativeSessionName } from './native-session-name.mjs';
6
7
  import { mobileToolRecovery, resolveMobileToolPath } from './tool-paths.mjs';
7
8
 
8
9
  const execFileAsync = promisify(execFile);
@@ -140,7 +141,8 @@ async function readAndroidHierarchy(node, env) {
140
141
  }
141
142
 
142
143
  async function readAndroidAgentDeviceHierarchy(serial, env) {
143
- const session = `mm-harness-observe-${process.pid}-${Date.now()}`;
144
+ const app = text(env.ANDROID_PACKAGE_ID) ?? 'io.metamask';
145
+ const session = nativeSessionName(env, serial, app);
144
146
  const client = createAgentDeviceClient({
145
147
  session,
146
148
  lockPolicy: 'reject',
@@ -152,13 +154,13 @@ async function readAndroidAgentDeviceHierarchy(serial, env) {
152
154
  target: 'mobile',
153
155
  serial,
154
156
  session,
155
- app: text(env.ANDROID_PACKAGE_ID) ?? 'io.metamask',
157
+ app,
156
158
  interactiveOnly: false,
157
159
  forceFull: true,
158
160
  });
159
161
  const hierarchy = flattenAgentDeviceHierarchy(
160
162
  snapshot.nodes,
161
- text(env.ANDROID_PACKAGE_ID) ?? 'io.metamask',
163
+ app,
162
164
  );
163
165
  if (hierarchy.nodeCount === 0) {
164
166
  throw new Error('Agent Device returned an empty Android accessibility hierarchy.');
@@ -0,0 +1,45 @@
1
+ import { createAgentDeviceUiTransport } from '@farmslot/expo-recipe';
2
+ import { createAgentDeviceClient } from 'agent-device';
3
+ import { nativeSessionName } from '../platform/native-session-name.mjs';
4
+
5
+ export function createSwapNativeSession(input) {
6
+ const env = { ...process.env, ...(input.context?.env ?? {}) };
7
+ const device = String(env.ADB_SERIAL ?? env.ANDROID_SERIAL ?? '').trim();
8
+ if (!device) throw new Error('Opaque Mobile Swap actions require one explicit Android device serial.');
9
+ const app = String(env.ANDROID_PACKAGE_ID ?? 'io.metamask');
10
+ const session = nativeSessionName(env, device, app);
11
+ const client = createAgentDeviceClient({ session, lockPolicy: 'reject', lockPlatform: 'android' });
12
+ const selection = { platform: 'android', target: 'mobile', serial: device, session };
13
+ const transport = createAgentDeviceUiTransport({ platform: 'android', device, app, session, client });
14
+ const context = {
15
+ nodeId: String(input.context?.nodeId ?? 'action'),
16
+ projectRoot: input.context.projectRoot,
17
+ artifactsDir: input.context.artifactsDir,
18
+ env,
19
+ };
20
+ return {
21
+ client,
22
+ transport,
23
+ selection,
24
+ context,
25
+ app,
26
+ execute: (action, node) => transport.execute(action, node, context),
27
+ snapshot: () => client.capture.snapshot({ ...selection, app, interactiveOnly: false, forceFull: true }),
28
+ };
29
+ }
30
+
31
+ export async function closeSwapNativeSession(session) {
32
+ try {
33
+ await session.transport.close();
34
+ } catch (error) {
35
+ if (!/No active session|SESSION_NOT_FOUND/iu.test(String(error?.message ?? error))) throw error;
36
+ }
37
+ }
38
+
39
+ export function nativeNode(snapshot, identifier) {
40
+ return snapshot.nodes.find((node) => String(node.identifier ?? '') === identifier);
41
+ }
42
+
43
+ export function nativeLabel(node) {
44
+ return String(node?.label ?? node?.value ?? '').replace(/\s+/gu, ' ').trim();
45
+ }