@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
@@ -6,19 +6,30 @@ runAdapter(async (input) => withExtensionPage(input, async (page) => {
6
6
  if (!/^[A-Z0-9._-]{2,20}$/u.test(symbol)) {
7
7
  throw new Error('metamask.assets.open_details requires a valid token symbol.');
8
8
  }
9
- const selector = await page.evaluate(`(() => {
9
+ const match = await page.evaluate(`(() => {
10
10
  const symbol = ${JSON.stringify(symbol)};
11
- for (const [index, row] of [...document.querySelectorAll('[data-testid="multichain-token-list-button"]')].entries()) {
11
+ const selectors = [];
12
+ for (const row of document.querySelectorAll('[data-testid="multichain-token-list-button"]')) {
13
+ const style = getComputedStyle(row);
14
+ const rect = row.getBoundingClientRect();
15
+ if (style.display === 'none' || style.visibility === 'hidden' || rect.width <= 0 || rect.height <= 0
16
+ || rect.bottom <= 0 || rect.right <= 0 || rect.top >= innerHeight || rect.left >= innerWidth) continue;
12
17
  const amount = String(row.querySelector('[data-testid="multichain-token-list-item-value"]')?.innerText || '').trim();
13
18
  const observed = amount.match(/\\b([A-Za-z][A-Za-z0-9._-]{1,19})$/u)?.[1]?.toUpperCase();
14
19
  if (observed !== symbol) continue;
15
- row.setAttribute('data-mm-harness-asset-index', String(index));
16
- return '[data-mm-harness-asset-index="' + index + '"]';
20
+ const parts = [];
21
+ for (let element = row; element; element = element.parentElement) {
22
+ const index = element.parentElement
23
+ ? [...element.parentElement.children].indexOf(element) + 1 : 1;
24
+ parts.unshift(element.localName + ':nth-child(' + index + ')');
25
+ }
26
+ selectors.push(parts.join(' > '));
17
27
  }
18
- return null;
28
+ return { count: selectors.length, selector: selectors.length === 1 ? selectors[0] : null };
19
29
  })()`);
20
- if (!selector) throw new Error(`Visible asset ${symbol} was not found.`);
21
- await page.click(selector);
30
+ if (!match.count) throw new Error(`Visible asset ${symbol} was not found.`);
31
+ if (match.count !== 1) throw new Error(`Visible asset ${symbol} matches ${match.count} rows; selection is ambiguous.\nNext: use a uniquely visible token symbol or narrow the product network filter before retrying.`);
32
+ await page.click(match.selector);
22
33
  const deadline = Date.now() + timeoutMs;
23
34
  while (Date.now() <= deadline) {
24
35
  if (String(await page.evaluate('location.hash')).startsWith('#/asset/')) {
@@ -6,17 +6,29 @@ function clean(value) {
6
6
  return normalized ? normalized.slice(0, 500) : null;
7
7
  }
8
8
 
9
- runAdapter(async (input) => withExtensionPage(input, async (page) => {
10
- const expectedSymbol = clean(input.node?.expected_symbol)?.toUpperCase() ?? null;
11
- const requireChart = input.node?.require_chart === true;
12
- const requireMarketCap = input.node?.require_market_cap === true;
13
- const raw = await page.evaluate(`(() => {
9
+ runAdapter(async (input) =>
10
+ withExtensionPage(input, async (page) => {
11
+ const expectedSymbol = clean(input.node?.expected_symbol)?.toUpperCase() ?? null;
12
+ const requireChart = input.node?.require_chart === true;
13
+ const requireMarketCap = input.node?.require_market_cap === true;
14
+ const timeoutMs = Number(input.node?.timeout_ms ?? 30000);
15
+ const deadline = Date.now() + timeoutMs;
16
+ let samples = 0;
17
+ while (true) {
18
+ const raw = await page.evaluate(`(() => {
14
19
  const text = (selector) => {
15
20
  const value = document.querySelector(selector)?.innerText;
16
21
  return typeof value === 'string' ? value.replace(/\\s+/gu, ' ').trim().slice(0, 500) : null;
17
22
  };
18
23
  const body = String(document.body?.innerText || '').replace(/\\s+/gu, ' ').trim();
19
24
  const name = text('[data-testid="asset-name"]');
25
+ const chart = document.querySelector('[data-testid="asset-price-chart"]');
26
+ const price = document.querySelector('[data-testid="asset-hovered-price"]');
27
+ const priceText = String(price?.innerText || '').trim();
28
+ const chartLabels = [...(chart?.querySelectorAll('p') || [])]
29
+ .filter(element => !element.closest('button, [role="button"]'))
30
+ .map(element => String(element.innerText || '').trim())
31
+ .filter(value => /[0-9]/u.test(value));
20
32
  return {
21
33
  route: String(location.hash),
22
34
  name,
@@ -25,33 +37,53 @@ runAdapter(async (input) => withExtensionPage(input, async (page) => {
25
37
  marketCap: text('[data-testid="asset-market-cap"]'),
26
38
  chartRendered: Boolean(document.querySelector('[data-testid="asset-price-chart"] canvas')),
27
39
  chartEmpty: Boolean(document.querySelector('[data-testid="asset-chart-empty-state"]')),
40
+ chartHasPriceLabels: chartLabels.length >= 2,
41
+ priceSettled: Boolean(price && /[0-9]/u.test(priceText) && Number(getComputedStyle(price).opacity) === 1),
28
42
  malformed: /(?:\\bNaN\\b|\\bundefined\\b|\\bnull\\b)/u.test(body),
29
43
  hasActivity: /\\bYour activity\\b/u.test(body),
30
44
  };
31
45
  })()`);
46
+ samples += 1;
32
47
 
33
- if (!raw.route.startsWith('#/asset/')) {
34
- throw new Error(`Expected an asset details route, but observed ${raw.route || 'no route'}.`);
35
- }
36
- if (expectedSymbol && raw.symbol !== expectedSymbol) {
37
- throw new Error(`Expected ${expectedSymbol} asset details, but observed ${raw.symbol || 'no symbol'}.`);
38
- }
39
- if (requireChart && (!raw.chartRendered || raw.chartEmpty)) {
40
- throw new Error('Expected a rendered asset price chart without an empty state.');
41
- }
42
- if (requireMarketCap && !raw.marketCap) {
43
- throw new Error('Expected a formatted asset market cap.');
44
- }
45
- if (raw.malformed) {
46
- throw new Error('Asset details contain a malformed visible value.');
47
- }
48
+ if (!raw.route.startsWith('#/asset/')) {
49
+ throw new Error(`Expected an asset details route, but observed ${raw.route || 'no route'}.`);
50
+ }
51
+ if (expectedSymbol && raw.symbol && raw.symbol !== expectedSymbol) {
52
+ throw new Error(
53
+ `Expected ${expectedSymbol} asset details, but observed ${raw.symbol || 'no symbol'}.`,
54
+ );
55
+ }
56
+ if (raw.malformed) {
57
+ throw new Error('Asset details contain a malformed visible value.');
58
+ }
48
59
 
49
- return {
50
- action: input.action,
51
- source: 'visible-ui',
52
- platform: 'extension',
53
- surface: 'asset-details',
54
- ...raw,
55
- proofPath: 'visible-dom-accessibility',
56
- };
57
- }));
60
+ const pending = [];
61
+ if (expectedSymbol && !raw.symbol) pending.push('asset symbol');
62
+ if (
63
+ requireChart &&
64
+ (!raw.chartRendered || raw.chartEmpty || !raw.chartHasPriceLabels || !raw.priceSettled)
65
+ )
66
+ pending.push('settled chart data and price');
67
+ if (requireMarketCap && !raw.marketCap) pending.push('formatted market cap');
68
+ if (pending.length) {
69
+ const remaining = deadline - Date.now();
70
+ if (remaining <= 0)
71
+ throw new Error(
72
+ `Asset details did not show ${pending.join(', ')} within ${timeoutMs}ms. Last state: ${JSON.stringify(raw)}\nNext: inspect the asset page and captured failure; do not refresh to manufacture readiness.`,
73
+ );
74
+ await new Promise((resolve) => setTimeout(resolve, Math.min(100, remaining)));
75
+ continue;
76
+ }
77
+
78
+ return {
79
+ action: input.action,
80
+ source: 'visible-ui',
81
+ platform: 'extension',
82
+ surface: 'asset-details',
83
+ ...raw,
84
+ samples,
85
+ proofPath: 'visible-dom-accessibility',
86
+ };
87
+ }
88
+ }),
89
+ );
@@ -12,7 +12,8 @@ runAdapter(async (input) => withExtensionPage(input, async (page) => {
12
12
  .filter((element) => {
13
13
  const style = getComputedStyle(element);
14
14
  const rect = element.getBoundingClientRect();
15
- return style.display !== 'none' && style.visibility !== 'hidden' && rect.width > 0 && rect.height > 0;
15
+ return style.display !== 'none' && style.visibility !== 'hidden' && rect.width > 0 && rect.height > 0
16
+ && rect.bottom > 0 && rect.right > 0 && rect.top < innerHeight && rect.left < innerWidth;
16
17
  })
17
18
  .slice(0, maximum)
18
19
  .map((element) => {
@@ -38,6 +39,7 @@ runAdapter(async (input) => withExtensionPage(input, async (page) => {
38
39
  return {
39
40
  route: location.hash,
40
41
  title: document.title,
42
+ lowValueExpanded: document.querySelector('[data-testid="low-value-assets-toggle"]')?.getAttribute('aria-expanded') ?? null,
41
43
  items: rows,
42
44
  truncated: document.querySelectorAll('[data-testid="multichain-token-list-button"]').length > rows.length,
43
45
  };
@@ -45,6 +47,7 @@ runAdapter(async (input) => withExtensionPage(input, async (page) => {
45
47
  return {
46
48
  action: input.action,
47
49
  ...normalizeVisibleAssetsState(raw, options, 'extension'),
50
+ lowValueExpanded: raw.lowValueExpanded,
48
51
  proofPath: 'visible-dom-accessibility',
49
52
  };
50
53
  }));
@@ -11,16 +11,23 @@ runAdapter(async (input) => withExtensionPage(input, async (page) => {
11
11
  await openHome(page, timeoutMs);
12
12
  const filter = dataTestId('sort-by-networks');
13
13
  const all = dataTestId('home-network-filter-all-default');
14
+ const deadline = Date.now() + timeoutMs;
15
+ const remaining = () => Math.max(1, deadline - Date.now());
16
+ const readLabel = () => page.evaluate(`String(document.querySelector(${JSON.stringify(filter)})?.innerText || '')`);
17
+ const alreadySelected = /All (?:default )?networks/iu.test(await readLabel());
18
+ if (await visible(page, all)) {
19
+ await page.waitFor({ selector: all, expected: 'visible', timeoutMs: remaining() });
20
+ await page.click(all);
21
+ } else if (!alreadySelected) {
22
+ await waitFor(page, () => visible(page, filter), 'Network filter did not become visible.', remaining());
23
+ await page.click(filter);
24
+ await page.waitFor({ selector: all, expected: 'visible', timeoutMs: remaining() });
25
+ await page.click(all);
26
+ }
14
27
  const selected = await waitFor(page, async () => {
15
- const label = await page.evaluate(`String(document.querySelector(${JSON.stringify(filter)})?.innerText || '')`);
16
- if (/All (?:default )?networks/iu.test(label)) return label;
17
- if (await visible(page, all)) {
18
- await page.click(all);
19
- } else if (await visible(page, filter)) {
20
- await page.click(filter);
21
- }
22
- return null;
23
- }, 'All networks did not become selected.', timeoutMs);
28
+ const label = await readLabel();
29
+ return /All (?:default )?networks/iu.test(label) && !(await visible(page, all)) ? label : null;
30
+ }, 'All networks did not become selected with its menu closed.', remaining());
24
31
  return {
25
32
  action: input.action,
26
33
  scope,
@@ -1,8 +1,9 @@
1
1
  import { dataTestId, runAdapter, withExtensionPage } from '../platform/cdp.mjs';
2
+ import { verifyHomeIdentity } from './home-token-identity.mjs';
2
3
 
3
4
  const OPTIONS = dataTestId('asset-list-control-bar-action-button');
4
5
  const MANAGE = dataTestId('manageTokens__button');
5
- const PAGE = dataTestId('token-management-page');
6
+ const PAGE = `${dataTestId('parent-selector-token-management-page')}, ${dataTestId('token-management-page')}`;
6
7
  const SEARCH = dataTestId('token-management-search-input');
7
8
  const BACK = dataTestId('token-management-header-back-button');
8
9
  const LOW_VALUE = dataTestId('low-value-assets-toggle');
@@ -27,12 +28,15 @@ async function waitFor(page, predicate, message, timeoutMs) {
27
28
  throw new Error(message);
28
29
  }
29
30
 
30
- async function matchingToggle(page, query) {
31
+ async function matchingToggle(page, query, assetId) {
31
32
  return page.evaluate(`(() => {
32
33
  const query = ${JSON.stringify(query.toLowerCase())};
34
+ const assetId = ${JSON.stringify(assetId)};
33
35
  const candidates = [...document.querySelectorAll('[data-testid^="token-management-cell-search-"][data-testid$="-toggle-control"]')];
36
+ const matches = [];
34
37
  for (const element of candidates) {
35
38
  const testId = element.getAttribute('data-testid');
39
+ if (assetId && testId !== 'token-management-cell-search-' + assetId + '-toggle-control') continue;
36
40
  const row = document.querySelector('[data-testid="' + testId.slice(0, -'-toggle-control'.length) + '"]');
37
41
  const label = String(row?.innerText || element.innerText || '').replace(/\\s+/gu, ' ').trim().slice(0, 500);
38
42
  const words = label.toLowerCase().split(/[^a-z0-9]+/u).filter(Boolean);
@@ -40,28 +44,48 @@ async function matchingToggle(page, query) {
40
44
  const style = getComputedStyle(element);
41
45
  const rect = element.getBoundingClientRect();
42
46
  if (style.display === 'none' || style.visibility === 'hidden' || rect.width <= 0 || rect.height <= 0) continue;
43
- return {
47
+ matches.push({
44
48
  testId,
45
49
  label,
46
50
  enabled: Boolean(element.querySelector('.toggle-button--on')),
47
- };
51
+ busy: element.getAttribute('aria-busy') === 'true',
52
+ });
48
53
  }
49
- return null;
54
+ if (matches.length > 1) throw new Error('Token management matched ' + matches.length + ' rows; query is ambiguous.\\nNext: mm-harness actions --action metamask.assets.set_token_visibility --json');
55
+ return matches[0] || null;
50
56
  })()`);
51
57
  }
52
58
 
53
- async function homeToken(page, query) {
59
+ async function homeToken(page, query, verifyIdentity = false) {
54
60
  return page.evaluate(`(() => {
55
61
  const query = ${JSON.stringify(query.toLowerCase())};
62
+ const verifyIdentity = ${JSON.stringify(verifyIdentity)};
63
+ const matches = [];
56
64
  for (const row of document.querySelectorAll('[data-testid="multichain-token-list-button"]')) {
57
65
  const name = String(row.querySelector('[data-testid="multichain-token-list-item-token-name"]')?.innerText || '').replace(/\\s+/gu, ' ').trim();
58
66
  const amount = String(row.querySelector('[data-testid="multichain-token-list-item-value"]')?.innerText || '').replace(/\\s+/gu, ' ').trim();
59
67
  const symbol = amount.match(/\\b([A-Za-z][A-Za-z0-9]{1,11})$/u)?.[1] || '';
60
68
  if (symbol.toLowerCase() === query || name.toLowerCase().includes(query)) {
61
- return { name, symbol: symbol.toUpperCase(), amount };
69
+ const rect = row.getBoundingClientRect();
70
+ const style = getComputedStyle(row);
71
+ if (rect.width <= 0 || rect.height <= 0 || style.display === 'none' || style.visibility === 'hidden') continue;
72
+ matches.push({ row, name, symbol: symbol.toUpperCase(), amount });
62
73
  }
63
74
  }
64
- return null;
75
+ if (verifyIdentity && matches.length > 1) throw new Error('Home token query is ambiguous; cannot verify asset identity.');
76
+ const match = matches[0];
77
+ if (!match) return null;
78
+ const { row, ...token } = match;
79
+ if (verifyIdentity) {
80
+ row.scrollIntoView({ block: 'center', inline: 'nearest' });
81
+ const rect = row.getBoundingClientRect();
82
+ const x = rect.left + rect.width / 2;
83
+ const y = rect.top + rect.height / 2;
84
+ const hit = document.elementFromPoint(x, y);
85
+ if (!hit || !row.contains(hit)) return null;
86
+ token.point = { x, y };
87
+ }
88
+ return token;
65
89
  })()`);
66
90
  }
67
91
 
@@ -77,6 +101,31 @@ runAdapter(async (input) => withExtensionPage(input, async (page) => {
77
101
  const requireUnchanged = input.node?.require_unchanged === true;
78
102
  const requireChange = input.node?.require_change === true;
79
103
  const timeoutMs = Number(input.node?.timeout_ms ?? 45_000);
104
+ const assetId = String(input.node?.asset_id ?? '').trim().toLowerCase();
105
+ if (assetId && !/^eip155:[1-9][0-9]{0,19}\/erc20:0x[0-9a-f]{40}$/u.test(assetId)) {
106
+ throw new Error('asset_id requires an ERC-20 CAIP asset ID.\nNext: mm-harness actions --action metamask.assets.set_token_visibility --json');
107
+ }
108
+ if (assetId && !requested && (!requireChange || requireUnchanged)) {
109
+ throw new Error('Exact token hiding requires require_change=true and require_unchanged=false.\nNext: mm-harness actions --action metamask.assets.set_token_visibility --json');
110
+ }
111
+
112
+ let identity;
113
+ if (assetId && !requested) {
114
+ if (await visible(page, LOW_VALUE)) {
115
+ const expanded = await page.evaluate(`document.querySelector(${JSON.stringify(LOW_VALUE)})?.getAttribute('aria-expanded') === 'true'`);
116
+ if (!expanded) await page.click(LOW_VALUE);
117
+ }
118
+ const token = await waitFor(
119
+ page,
120
+ () => homeToken(page, query, true),
121
+ `${query} must be visible on Home before its exact identity can be hidden.`,
122
+ timeoutMs,
123
+ );
124
+ identity = {
125
+ ...await verifyHomeIdentity(page, token, assetId, timeoutMs),
126
+ verifiedAt: 'before-hide',
127
+ };
128
+ }
80
129
 
81
130
  if (!(await visible(page, MANAGE))) await page.click(OPTIONS);
82
131
  await waitFor(page, () => visible(page, MANAGE), 'Manage tokens did not become visible.', timeoutMs);
@@ -85,8 +134,11 @@ runAdapter(async (input) => withExtensionPage(input, async (page) => {
85
134
  await page.setInput(SEARCH, query);
86
135
  const before = await waitFor(
87
136
  page,
88
- () => matchingToggle(page, query),
89
- `Token management returned no visible result for ${query}.`,
137
+ async () => {
138
+ const state = await matchingToggle(page, query, assetId);
139
+ return state && !state.busy ? state : null;
140
+ },
141
+ `Token management returned no settled visible result for ${query}.`,
90
142
  timeoutMs,
91
143
  );
92
144
  if (requireUnchanged && before.enabled !== requested) {
@@ -101,8 +153,8 @@ runAdapter(async (input) => withExtensionPage(input, async (page) => {
101
153
  const after = await waitFor(
102
154
  page,
103
155
  async () => {
104
- const state = await matchingToggle(page, query);
105
- return state?.enabled === requested ? state : null;
156
+ const state = await matchingToggle(page, query, assetId);
157
+ return state && !state.busy && state.enabled === requested ? state : null;
106
158
  },
107
159
  `${query} token visibility did not become ${requested ? 'enabled' : 'disabled'}.`,
108
160
  timeoutMs,
@@ -110,14 +162,26 @@ runAdapter(async (input) => withExtensionPage(input, async (page) => {
110
162
  await page.click(BACK);
111
163
  await waitFor(page, () => visible(page, OPTIONS), 'Token list did not reopen.', timeoutMs);
112
164
 
113
- if (requested && (await visible(page, LOW_VALUE))) {
165
+ if ((requested || assetId) && (await visible(page, LOW_VALUE))) {
114
166
  const expanded = await page.evaluate(`document.querySelector(${JSON.stringify(LOW_VALUE)})?.getAttribute('aria-expanded') === 'true'`);
115
167
  if (!expanded) await page.click(LOW_VALUE);
116
168
  }
117
- const token = requested
118
- ? await waitFor(page, () => homeToken(page, query), `${query} did not appear in the token list.`, timeoutMs)
119
- : await homeToken(page, query);
120
- if (!requested && token) throw new Error(`${query} remained visible after disabling it.`);
169
+ let token = null;
170
+ if (requested) {
171
+ token = await waitFor(page, () => homeToken(page, query, Boolean(assetId)), `${query} did not appear in the token list.`, timeoutMs);
172
+ if (assetId) identity = await verifyHomeIdentity(page, token, assetId, timeoutMs);
173
+ } else if (assetId) {
174
+ await waitFor(
175
+ page,
176
+ async () => !(await homeToken(page, query)),
177
+ `${query} remained visible after disabling it.`,
178
+ timeoutMs,
179
+ );
180
+ identity.homeAbsent = true;
181
+ } else {
182
+ token = await homeToken(page, query);
183
+ if (token) throw new Error(`${query} remained visible after disabling it.`);
184
+ }
121
185
 
122
186
  return {
123
187
  action: input.action,
@@ -126,8 +190,11 @@ runAdapter(async (input) => withExtensionPage(input, async (page) => {
126
190
  requireUnchanged,
127
191
  requireChange,
128
192
  changed: before.enabled !== after.enabled,
193
+ beforeEnabled: before.enabled,
194
+ afterEnabled: after.enabled,
129
195
  searchResult: after.label,
130
196
  toggleTestId: after.testId,
197
+ ...(identity ? { identity } : {}),
131
198
  token,
132
199
  proofPath: 'trusted-pointer-visible-ui',
133
200
  };
@@ -45,9 +45,9 @@ function assertDeclining(rows) {
45
45
  }
46
46
  }
47
47
 
48
- async function choose(page, selector) {
48
+ async function openMenu(page) {
49
49
  const isVisible = async () => page.evaluate(`(() => {
50
- const element = document.querySelector(${JSON.stringify(selector)});
50
+ const element = document.querySelector(${JSON.stringify(ALPHABETICAL)});
51
51
  if (!element) return false;
52
52
  const style = getComputedStyle(element);
53
53
  const rect = element.getBoundingClientRect();
@@ -57,24 +57,71 @@ async function choose(page, selector) {
57
57
  const deadline = Date.now() + 5_000;
58
58
  while (!(await isVisible())) {
59
59
  if (Date.now() >= deadline) throw new Error('Asset sort menu did not become visible.');
60
- await page.evaluate('new Promise((resolve) => setTimeout(resolve, 100))');
60
+ await new Promise(resolve => setTimeout(resolve, 100));
61
+ }
62
+ }
63
+
64
+ async function selectedSort(page) {
65
+ await openMenu(page);
66
+ const selected = await page.evaluate(`(() => ${JSON.stringify([ALPHABETICAL, DECLINING])}
67
+ .filter(selector => document.querySelector(selector)?.classList.contains('selectable-list-item--selected')))()`);
68
+ if (selected.length !== 1) throw new Error('Asset sort preference is unavailable or ambiguous; no sort will be changed.');
69
+ return selected[0];
70
+ }
71
+
72
+ async function choose(page, selector) {
73
+ const current = await selectedSort(page);
74
+ await page.click(current === selector ? TOGGLE : selector);
75
+ await page.waitForExpression(`(() => {
76
+ const element = document.querySelector(${JSON.stringify(ALPHABETICAL)});
77
+ if (!element) return true;
78
+ const rect = element.getBoundingClientRect();
79
+ const style = getComputedStyle(element);
80
+ return rect.width === 0 || rect.height === 0 || style.display === 'none' || style.visibility === 'hidden';
81
+ })()`, { timeoutMs: 5000 });
82
+ }
83
+
84
+ async function waitForRows(page, assertion) {
85
+ const deadline = Date.now() + 5000;
86
+ while (true) {
87
+ const rows = await readRows(page);
88
+ try {
89
+ assertion(rows);
90
+ return rows;
91
+ } catch (error) {
92
+ if (Date.now() >= deadline) throw error;
93
+ }
94
+ await new Promise(resolve => setTimeout(resolve, 100));
61
95
  }
62
- await page.click(selector);
63
- await page.evaluate('new Promise((resolve) => setTimeout(resolve, 500))');
64
96
  }
65
97
 
66
98
  runAdapter(async (input) => withExtensionPage(input, async (page) => {
67
- if (!String(await page.evaluate('location.hash')).startsWith('#/')) {
99
+ if (!['#/', '#', ''].includes(String(await page.evaluate('location.hash')))) {
68
100
  throw new Error('metamask.assets.verify_sorting requires the wallet home token list.');
69
101
  }
70
102
 
71
- await choose(page, ALPHABETICAL);
72
- const alphabetical = await readRows(page);
73
- assertAlphabetical(alphabetical);
74
-
75
- await choose(page, DECLINING);
76
- const declining = await readRows(page);
77
- assertDeclining(declining);
103
+ const initialSort = await selectedSort(page);
104
+ let alphabetical;
105
+ let declining;
106
+ let proofError;
107
+ try {
108
+ await choose(page, ALPHABETICAL);
109
+ alphabetical = await waitForRows(page, assertAlphabetical);
110
+ await choose(page, DECLINING);
111
+ declining = await waitForRows(page, assertDeclining);
112
+ } catch (error) {
113
+ proofError = error;
114
+ }
115
+ try {
116
+ await choose(page, initialSort);
117
+ await openMenu(page);
118
+ await page.waitForExpression(`document.querySelector(${JSON.stringify(initialSort)})?.classList.contains('selectable-list-item--selected') === true`, { timeoutMs: 5000 });
119
+ await page.click(TOGGLE);
120
+ } catch (restoreError) {
121
+ if (proofError) throw new AggregateError([proofError, restoreError], 'Asset sorting proof and preference restoration both failed.');
122
+ throw restoreError;
123
+ }
124
+ if (proofError) throw proofError;
78
125
 
79
126
  return {
80
127
  action: input.action,
@@ -83,7 +130,8 @@ runAdapter(async (input) => withExtensionPage(input, async (page) => {
83
130
  surface: 'token-list',
84
131
  alphabetical: alphabetical.map((row) => row.name),
85
132
  decliningFiat: declining.map((row) => row.fiat),
86
- restoredSort: 'declining-balance',
133
+ initialSort: initialSort === ALPHABETICAL ? 'alphabetical' : 'declining-balance',
134
+ restoredSort: initialSort === ALPHABETICAL ? 'alphabetical' : 'declining-balance',
87
135
  proofPath: 'trusted-pointer-visible-ui',
88
136
  };
89
137
  }));
@@ -37,17 +37,19 @@ runAdapter(async (input) => withExtensionPage(input, async (page) => {
37
37
  await page.setInput(dataTestId('network-form-chain-id'), chainId);
38
38
  await page.setInput(dataTestId('network-form-ticker-input'), symbol);
39
39
 
40
- await page.evaluate('new Promise((resolve) => setTimeout(resolve, 1000))');
41
- let addRpc;
42
- for (let attempt = 0; attempt < 3 && !addRpc; attempt += 1) {
43
- await page.click(dataTestId('test-add-rpc-drop-down'));
44
- await page.evaluate('new Promise((resolve) => setTimeout(resolve, 250))');
45
- addRpc = await semanticButtonOrNull(page, 'Add RPC URL');
46
- }
47
- if (!addRpc) throw new Error('Visible button "Add RPC URL" was not found after opening the RPC menu.');
40
+ await page.click(dataTestId('test-add-rpc-drop-down'));
41
+ const addRpc = await waitFor(
42
+ page,
43
+ () => semanticButtonOrNull(page, 'Add RPC URL'),
44
+ 'Add RPC URL did not become visible after opening the menu.',
45
+ timeoutMs,
46
+ );
47
+ await page.waitFor({ selector: addRpc, expected: 'visible', timeoutMs });
48
48
  await page.click(addRpc);
49
49
  await page.setInput(dataTestId('rpc-url-input-test'), rpcUrl);
50
- await page.click(dataTestId('page-container-footer-next'));
50
+ const rpcSubmit = `${dataTestId('page-container-footer-next')}:not(:disabled):not([aria-disabled="true"])`;
51
+ await page.waitFor({ selector: rpcSubmit, expected: 'visible', timeoutMs });
52
+ await page.click(rpcSubmit);
51
53
  await waitFor(
52
54
  page,
53
55
  () => page.evaluate(`!document.querySelector(${JSON.stringify(dataTestId('rpc-url-input-test'))})`),
@@ -56,14 +58,14 @@ runAdapter(async (input) => withExtensionPage(input, async (page) => {
56
58
  );
57
59
 
58
60
  if (explorerUrl) {
59
- await page.evaluate('new Promise((resolve) => setTimeout(resolve, 1000))');
60
- let addExplorer;
61
- for (let attempt = 0; attempt < 3 && !addExplorer; attempt += 1) {
62
- await page.click(dataTestId('test-explorer-drop-down'));
63
- await page.evaluate('new Promise((resolve) => setTimeout(resolve, 250))');
64
- addExplorer = await semanticButtonOrNull(page, 'Add a block explorer URL');
65
- }
66
- if (!addExplorer) throw new Error('Visible button "Add a block explorer URL" was not found after opening the explorer menu.');
61
+ await page.click(dataTestId('test-explorer-drop-down'));
62
+ const addExplorer = await waitFor(
63
+ page,
64
+ () => semanticButtonOrNull(page, 'Add a block explorer URL'),
65
+ 'Add a block explorer URL did not become visible after opening the menu.',
66
+ timeoutMs,
67
+ );
68
+ await page.waitFor({ selector: addExplorer, expected: 'visible', timeoutMs });
67
69
  await page.click(addExplorer);
68
70
  await page.setInput(dataTestId('explorer-url-input'), explorerUrl);
69
71
  await page.click(await semanticButton(page, 'Add URL'));
@@ -90,7 +90,7 @@ export async function openNetworkManager(page, timeoutMs) {
90
90
  const networks = dataTestId('global-menu-networks');
91
91
  const deadline = Date.now() + timeoutMs;
92
92
  await clickUncovered(page, menu, deadline);
93
- await waitFor(page, () => visible(page, networks), 'Networks did not become visible in the account menu.', timeoutMs);
93
+ await page.waitFor({ selector: networks, expected: 'visible', timeoutMs: Math.max(1, deadline - Date.now()) });
94
94
  await page.click(networks);
95
95
  await waitFor(
96
96
  page,
@@ -115,7 +115,9 @@ export async function findNetworkItem(page, network, chainId) {
115
115
  const label = String(element.innerText || '').replace(/\\s+/gu, ' ').trim().slice(0, 500);
116
116
  const nameMatches = [...element.querySelectorAll('[data-testid]')]
117
117
  .some((child) => String(child.getAttribute('data-testid') || '').toLowerCase() === network);
118
- if (chainIds.has(suffix) && nameMatches) return { testId, label, suffix };
118
+ if (!chainIds.has(suffix)) continue;
119
+ if (!nameMatches) throw new Error('Chain ' + ${JSON.stringify(chainId)} + ' is already configured under a different name.\\nNext: mm-harness call ui.locators --adapter extension --json');
120
+ return { testId, label, suffix };
119
121
  }
120
122
  return null;
121
123
  })()`);
@@ -128,6 +130,7 @@ export function assertNetworkAdditionAllowed(item, requireAbsent, network, chain
128
130
  }
129
131
 
130
132
  export async function selectHomeNetwork(page, network, chainId, timeoutMs) {
133
+ const deadline = Date.now() + timeoutMs;
131
134
  const sort = dataTestId('sort-by-networks');
132
135
  const chainHex = `0x${chainId.toString(16)}`;
133
136
  const choices = [
@@ -135,13 +138,15 @@ export async function selectHomeNetwork(page, network, chainId, timeoutMs) {
135
138
  dataTestId(`home-network-filter-custom-${chainHex}`),
136
139
  dataTestId(`home-network-filter-additional-${chainHex}`),
137
140
  ];
138
- await page.click(sort);
141
+ if (!(await visible(page, dataTestId('home-network-filter-all-default')))) await page.click(sort);
139
142
  const choice = await waitFor(page, async () => {
140
143
  for (const candidate of choices) {
141
144
  if (await visible(page, candidate)) return candidate;
142
145
  }
143
146
  return null;
144
147
  }, `${network} did not appear in the home network filter.`, timeoutMs);
148
+ await page.scroll({ selector: choice, intoView: true });
149
+ await page.waitFor({ selector: choice, expected: 'visible', timeoutMs: Math.max(1, deadline - Date.now()) });
145
150
  await page.click(choice);
146
151
  await waitFor(page, async () => {
147
152
  const label = await page.evaluate(`String(document.querySelector(${JSON.stringify(sort)})?.innerText || '')`);
@@ -40,6 +40,7 @@ async function capture(page, maximum) {
40
40
  runAdapter(async (input) => withExtensionPage(input, async (page) => {
41
41
  const options = resolveVisibleNetworkOptions(input.node);
42
42
  const before = await capture(page, options.assets.maxItems);
43
+ normalizeVisibleNetworkState(before, before, options, 'extension');
43
44
  if (options.settleMs > 0) await sleep(options.settleMs);
44
45
  const after = options.settleMs > 0 ? await capture(page, options.assets.maxItems) : before;
45
46
  return {