@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
@@ -0,0 +1,227 @@
1
+ import { dataTestId, runAdapter, withExtensionPage } from '../platform/cdp.mjs';
2
+
3
+ const OPTIONS = dataTestId('asset-list-control-bar-action-button');
4
+ const MANAGE = dataTestId('manageTokens__button');
5
+ const ADD = dataTestId('token-management-add-custom-token-button');
6
+ const PAGE = dataTestId('custom-token-import-page');
7
+ const NETWORK = dataTestId('network-selector');
8
+ const ADDRESS = dataTestId('custom-token-import-address-input');
9
+ const SYMBOL = dataTestId('custom-token-import-symbol-input');
10
+ const DECIMALS = dataTestId('custom-token-import-decimal-input');
11
+ const SUBMIT = dataTestId('custom-token-import-submit-button');
12
+ const SUCCESS = dataTestId('token-management-custom-token-success-toast');
13
+ const BACK = dataTestId('token-management-header-back-button');
14
+ const LOW_VALUE = dataTestId('low-value-assets-toggle');
15
+
16
+ async function visible(page, selector) {
17
+ return page.evaluate(`(() => {
18
+ const element = document.querySelector(${JSON.stringify(selector)});
19
+ if (!element) return false;
20
+ const style = getComputedStyle(element);
21
+ const rect = element.getBoundingClientRect();
22
+ return style.display !== 'none' && style.visibility !== 'hidden' && rect.width > 0 && rect.height > 0;
23
+ })()`);
24
+ }
25
+
26
+ async function waitFor(page, predicate, message, timeoutMs) {
27
+ const deadline = Date.now() + timeoutMs;
28
+ while (Date.now() < deadline) {
29
+ const value = await predicate();
30
+ if (value) return value;
31
+ await page.evaluate('new Promise((resolve) => setTimeout(resolve, 150))');
32
+ }
33
+ throw new Error(message);
34
+ }
35
+
36
+ async function visibleButton(page, label) {
37
+ const marker = `mm-harness-${Date.now()}-${Math.random().toString(16).slice(2)}`;
38
+ const found = await page.evaluate(`(() => {
39
+ const label = ${JSON.stringify(label)};
40
+ const marker = ${JSON.stringify(marker)};
41
+ for (const element of document.querySelectorAll('button')) {
42
+ const text = String(element.innerText || element.textContent || '').replace(/\\s+/gu, ' ').trim();
43
+ const style = getComputedStyle(element);
44
+ const rect = element.getBoundingClientRect();
45
+ if (text === label && style.display !== 'none' && style.visibility !== 'hidden' && rect.width > 0 && rect.height > 0) {
46
+ element.setAttribute('data-mm-harness-target', marker);
47
+ return true;
48
+ }
49
+ }
50
+ return false;
51
+ })()`);
52
+ if (!found) throw new Error(`Visible button ${JSON.stringify(label)} was not found.`);
53
+ return `[data-mm-harness-target="${marker}"]`;
54
+ }
55
+
56
+ async function networkOption(page, network, chainId) {
57
+ return page.evaluate(`(() => {
58
+ const network = ${JSON.stringify(network.toLowerCase())};
59
+ const chainIds = new Set(${JSON.stringify([
60
+ String(chainId),
61
+ `0x${chainId.toString(16)}`,
62
+ `eip155:${chainId}`,
63
+ ].map((value) => value.toLowerCase()))});
64
+ for (const element of document.querySelectorAll('[data-testid^="network-list-item-"]')) {
65
+ const testId = String(element.getAttribute('data-testid') || '');
66
+ const suffix = testId.slice('network-list-item-'.length).toLowerCase();
67
+ const label = String(element.innerText || '').replace(/\\s+/gu, ' ').trim().slice(0, 500);
68
+ if (chainIds.has(suffix) || label.toLowerCase().includes(network)) return { testId, label };
69
+ }
70
+ return null;
71
+ })()`);
72
+ }
73
+
74
+ async function homeToken(page, symbol) {
75
+ return page.evaluate(`(() => {
76
+ const symbol = ${JSON.stringify(symbol.toLowerCase())};
77
+ for (const row of document.querySelectorAll('[data-testid="multichain-token-list-button"]')) {
78
+ const name = String(row.querySelector('[data-testid="multichain-token-list-item-token-name"]')?.innerText || '').replace(/\\s+/gu, ' ').trim();
79
+ const amount = String(row.querySelector('[data-testid="multichain-token-list-item-value"]')?.innerText || '').replace(/\\s+/gu, ' ').trim();
80
+ const observed = amount.match(/\\b([A-Za-z][A-Za-z0-9]{1,11})$/u)?.[1] || '';
81
+ if (observed.toLowerCase() === symbol) return { name, symbol: observed.toUpperCase(), amount };
82
+ }
83
+ return null;
84
+ })()`);
85
+ }
86
+
87
+ async function importedTokenIdentity(page, chainId, address, expectedSymbol) {
88
+ return page.evaluate(`(async () => {
89
+ const hooks = globalThis.stateHooks || {};
90
+ const root = (await hooks.getCleanAppState?.()) || hooks.store?.getState?.() || {};
91
+ const state = root.metamask || root;
92
+ const allTokens = state.allTokens;
93
+ const selectedAddress = String(state.selectedAddress || '').toLowerCase();
94
+ if (!allTokens || typeof allTokens !== 'object' || !selectedAddress) {
95
+ return { available: false, token: null };
96
+ }
97
+ const expectedChainId = ${JSON.stringify(chainId)};
98
+ const chainEntry = Object.entries(allTokens).find(([key]) => {
99
+ const normalized = String(key).toLowerCase();
100
+ const value = normalized.startsWith('eip155:')
101
+ ? Number(normalized.slice('eip155:'.length))
102
+ : normalized.startsWith('0x')
103
+ ? Number.parseInt(normalized.slice(2), 16)
104
+ : Number(normalized);
105
+ return value === expectedChainId;
106
+ });
107
+ const accountEntry = chainEntry && Object.entries(chainEntry[1] || {})
108
+ .find(([account]) => String(account).toLowerCase() === selectedAddress);
109
+ const tokens = Array.isArray(accountEntry?.[1]) ? accountEntry[1] : [];
110
+ const expectedAddress = ${JSON.stringify(address.toLowerCase())};
111
+ const expectedSymbol = ${JSON.stringify(expectedSymbol)};
112
+ const token = tokens.find((entry) => {
113
+ const tokenAddress = String(entry?.address || entry?.contractAddress || '').toLowerCase();
114
+ return tokenAddress === expectedAddress && String(entry?.symbol || '').toUpperCase() === expectedSymbol;
115
+ });
116
+ return {
117
+ available: true,
118
+ token: token ? {
119
+ address: String(token.address || token.contractAddress),
120
+ symbol: String(token.symbol),
121
+ decimals: token.decimals == null ? null : String(token.decimals),
122
+ chainId: chainEntry?.[0] || null,
123
+ } : null,
124
+ };
125
+ })()`, { awaitPromise: true });
126
+ }
127
+
128
+ runAdapter(async (input) => withExtensionPage(input, async (page) => {
129
+ const network = String(input.node?.network || '').trim();
130
+ const chainId = Number(input.node?.chain_id);
131
+ const address = String(input.node?.address || '').trim();
132
+ const expectedSymbol = String(input.node?.expected_symbol || '').trim().toUpperCase();
133
+ const expectedDecimals = input.node?.decimals == null ? null : String(input.node.decimals);
134
+ const timeoutMs = Number(input.node?.timeout_ms ?? 60_000);
135
+ if (!network || !Number.isInteger(chainId) || chainId <= 0 || !/^0x[0-9a-f]{40}$/iu.test(address) || !expectedSymbol) {
136
+ throw new Error('metamask.assets.import_custom_token requires network, positive chain_id, ERC-20 address, and expected_symbol.');
137
+ }
138
+
139
+ const existingToken = await homeToken(page, expectedSymbol);
140
+ const existingIdentity = existingToken
141
+ ? await importedTokenIdentity(page, chainId, address, expectedSymbol)
142
+ : null;
143
+ if (existingToken && existingIdentity?.token) {
144
+ return {
145
+ action: input.action,
146
+ network,
147
+ chainId,
148
+ address,
149
+ alreadyImported: true,
150
+ token: existingToken,
151
+ identity: existingIdentity.token,
152
+ proofPath: 'trusted-pointer-visible-ui',
153
+ };
154
+ }
155
+
156
+ if (!(await visible(page, MANAGE))) await page.click(OPTIONS);
157
+ await waitFor(page, () => visible(page, MANAGE), 'Manage tokens did not become visible.', timeoutMs);
158
+ await page.click(await visibleButton(page, 'Manage tokens'));
159
+ await waitFor(page, () => visible(page, ADD), 'Add a custom token did not become visible.', timeoutMs);
160
+ await page.click(await visibleButton(page, 'Add a custom token'));
161
+ await waitFor(page, () => visible(page, PAGE), 'Custom token import did not open.', timeoutMs);
162
+ await page.click(NETWORK);
163
+ const selectedNetwork = await waitFor(
164
+ page,
165
+ () => networkOption(page, network, chainId),
166
+ `${network} (${chainId}) was not available in the custom token network selector.`,
167
+ timeoutMs,
168
+ );
169
+ await page.click(dataTestId(selectedNetwork.testId));
170
+ await waitFor(page, () => visible(page, ADDRESS), 'Custom token address input did not become visible.', timeoutMs);
171
+ await page.evaluate('new Promise((resolve) => setTimeout(resolve, 2000))');
172
+ await page.setInput(ADDRESS, address);
173
+
174
+ let metadataSource = 'network';
175
+ let metadata;
176
+ try {
177
+ metadata = await waitFor(page, async () => {
178
+ const state = await page.evaluate(`(() => ({
179
+ symbol: document.querySelector(${JSON.stringify(SYMBOL)})?.value || null,
180
+ decimals: document.querySelector(${JSON.stringify(DECIMALS)})?.value || null,
181
+ enabled: !document.querySelector(${JSON.stringify(SUBMIT)})?.disabled,
182
+ }))()`);
183
+ return state.symbol && state.enabled ? state : null;
184
+ }, 'Custom token metadata did not resolve to an enabled import.', Math.min(timeoutMs, 15_000));
185
+ } catch (error) {
186
+ if (expectedDecimals === null) throw error;
187
+ metadataSource = 'manual';
188
+ await page.setInput(SYMBOL, expectedSymbol);
189
+ await page.setInput(DECIMALS, expectedDecimals);
190
+ metadata = await waitFor(page, async () => {
191
+ const state = await page.evaluate(`(() => ({
192
+ symbol: document.querySelector(${JSON.stringify(SYMBOL)})?.value || null,
193
+ decimals: document.querySelector(${JSON.stringify(DECIMALS)})?.value || null,
194
+ enabled: !document.querySelector(${JSON.stringify(SUBMIT)})?.disabled,
195
+ }))()`);
196
+ return state.enabled ? state : null;
197
+ }, 'Manually supplied token metadata did not enable the import.', timeoutMs);
198
+ }
199
+ if (String(metadata.symbol).toUpperCase() !== expectedSymbol) {
200
+ throw new Error(`Expected token symbol ${expectedSymbol}, but metadata resolved ${metadata.symbol}.`);
201
+ }
202
+ await page.click(SUBMIT);
203
+ await waitFor(page, () => visible(page, SUCCESS), 'Custom token success confirmation did not appear.', timeoutMs);
204
+ await page.click(BACK);
205
+ await waitFor(page, () => visible(page, OPTIONS), 'Token list did not reopen.', timeoutMs);
206
+ if (await visible(page, LOW_VALUE)) {
207
+ const expanded = await page.evaluate(`document.querySelector(${JSON.stringify(LOW_VALUE)})?.getAttribute('aria-expanded') === 'true'`);
208
+ if (!expanded) await page.click(LOW_VALUE);
209
+ }
210
+ const token = await waitFor(
211
+ page,
212
+ () => homeToken(page, expectedSymbol),
213
+ `${expectedSymbol} did not appear in the token list after import.`,
214
+ timeoutMs,
215
+ );
216
+
217
+ return {
218
+ action: input.action,
219
+ network: selectedNetwork.label || network,
220
+ chainId,
221
+ address,
222
+ metadata,
223
+ metadataSource,
224
+ token,
225
+ proofPath: 'trusted-pointer-visible-ui',
226
+ };
227
+ }));
@@ -0,0 +1,53 @@
1
+ import { runAdapter, withExtensionPage } from '../platform/cdp.mjs';
2
+ import { selectors, tokenRow, visible, waitFor } from './send.mjs';
3
+
4
+ runAdapter(async (input) => withExtensionPage(input, async (page) => {
5
+ const symbol = String(input.node?.symbol || '').trim().toUpperCase();
6
+ const recipient = String(input.node?.recipient || '').trim();
7
+ const amount = input.node?.amount == null ? null : String(input.node.amount).trim();
8
+ const useMax = input.node?.max === true;
9
+ const timeoutMs = Number(input.node?.timeout_ms ?? 30_000);
10
+ if (!symbol || !/^0x[0-9a-f]{40}$/iu.test(recipient) || (useMax === (amount !== null))) {
11
+ throw new Error('metamask.assets.prepare_send requires symbol, EVM recipient, and exactly one of amount or max=true.');
12
+ }
13
+ const token = await waitFor(page, () => tokenRow(page, symbol), `${symbol} was not visible for send.`, timeoutMs);
14
+ await page.click(token.selector);
15
+ await waitFor(page, async () => {
16
+ const route = await page.evaluate('location.hash');
17
+ return /^#\/(?:asset|token)\//u.test(route) ? route : null;
18
+ }, `${symbol} asset details did not open.`, timeoutMs);
19
+ const sendControl = await waitFor(page, async () => {
20
+ for (const selector of [selectors.nativeSend, selectors.send]) {
21
+ if (await visible(page, selector)) return selector;
22
+ }
23
+ return null;
24
+ }, 'Asset Send did not become visible.', timeoutMs);
25
+ await new Promise((resolve) => setTimeout(resolve, 500));
26
+ for (let attempt = 0; attempt < 3 && !(await visible(page, selectors.recipient)); attempt += 1) {
27
+ await page.click(sendControl);
28
+ await new Promise((resolve) => setTimeout(resolve, 500));
29
+ }
30
+ await waitFor(page, () => visible(page, selectors.recipient), 'Send recipient input did not become visible.', timeoutMs);
31
+ await page.setInput(selectors.recipient, recipient);
32
+ if (useMax) {
33
+ await page.clickText('Max');
34
+ } else {
35
+ await page.setInput(selectors.amount, amount);
36
+ }
37
+ const prepared = await waitFor(page, () => page.evaluate(`(() => {
38
+ const button = document.querySelector(${JSON.stringify(selectors.continue)});
39
+ const amount = document.querySelector(${JSON.stringify(selectors.amount)})?.value || '';
40
+ const balance = document.querySelector('[data-testid="send-amount-balance"]')?.innerText || '';
41
+ return button && !button.disabled ? { amount, balance } : null;
42
+ })()`), 'Prepared send did not enable Continue.', timeoutMs);
43
+ return {
44
+ action: input.action,
45
+ symbol,
46
+ recipient,
47
+ requestedAmount: amount,
48
+ max: useMax,
49
+ tokenBefore: token,
50
+ prepared,
51
+ proofPath: 'trusted-pointer-visible-ui',
52
+ };
53
+ }));
@@ -0,0 +1,57 @@
1
+ import { runAdapter, withExtensionPage } from '../platform/cdp.mjs';
2
+
3
+ function clean(value) {
4
+ if (typeof value !== 'string') return null;
5
+ const normalized = value.replace(/\s+/gu, ' ').trim();
6
+ return normalized ? normalized.slice(0, 500) : null;
7
+ }
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(`(() => {
14
+ const text = (selector) => {
15
+ const value = document.querySelector(selector)?.innerText;
16
+ return typeof value === 'string' ? value.replace(/\\s+/gu, ' ').trim().slice(0, 500) : null;
17
+ };
18
+ const body = String(document.body?.innerText || '').replace(/\\s+/gu, ' ').trim();
19
+ const name = text('[data-testid="asset-name"]');
20
+ return {
21
+ route: String(location.hash),
22
+ name,
23
+ symbol: name?.match(/\\b[A-Za-z][A-Za-z0-9]{1,11}\\b/u)?.[0]?.toUpperCase() || null,
24
+ network: text('[data-testid="asset-network"]'),
25
+ marketCap: text('[data-testid="asset-market-cap"]'),
26
+ chartRendered: Boolean(document.querySelector('[data-testid="asset-price-chart"] canvas')),
27
+ chartEmpty: Boolean(document.querySelector('[data-testid="asset-chart-empty-state"]')),
28
+ malformed: /(?:\\bNaN\\b|\\bundefined\\b|\\bnull\\b)/u.test(body),
29
+ hasActivity: /\\bYour activity\\b/u.test(body),
30
+ };
31
+ })()`);
32
+
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
+
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
+ }));
@@ -0,0 +1,50 @@
1
+ import { runAdapter, withExtensionPage } from '../platform/cdp.mjs';
2
+ import {
3
+ normalizeVisibleAssetsState,
4
+ resolveVisibleAssetsOptions,
5
+ } from '../../shared/assets/visible-state.mjs';
6
+
7
+ runAdapter(async (input) => withExtensionPage(input, async (page) => {
8
+ const options = resolveVisibleAssetsOptions(input.node);
9
+ const raw = await page.evaluate(`(() => {
10
+ const maximum = ${JSON.stringify(options.maxItems)};
11
+ const rows = [...document.querySelectorAll('[data-testid="multichain-token-list-button"]')]
12
+ .filter((element) => {
13
+ const style = getComputedStyle(element);
14
+ const rect = element.getBoundingClientRect();
15
+ return style.display !== 'none' && style.visibility !== 'hidden' && rect.width > 0 && rect.height > 0;
16
+ })
17
+ .slice(0, maximum)
18
+ .map((element) => {
19
+ const change = element.querySelector('[data-testid^="token-increase-decrease-percentage-"]');
20
+ const changeClass = String(change?.className || '');
21
+ const priceChangeTone = changeClass.includes('color-error')
22
+ ? 'negative'
23
+ : changeClass.includes('color-success')
24
+ ? 'positive'
25
+ : changeClass.includes('color-text-default')
26
+ ? 'neutral'
27
+ : null;
28
+ return {
29
+ testId: element.getAttribute('data-testid'),
30
+ label: String(element.getAttribute('aria-label') || element.innerText || '').replace(/\\s+/gu, ' ').trim(),
31
+ name: element.querySelector('[data-testid="multichain-token-list-item-token-name"]')?.innerText,
32
+ fiat: element.querySelector('[data-testid="multichain-token-list-item-secondary-value"]')?.innerText,
33
+ amount: element.querySelector('[data-testid="multichain-token-list-item-value"]')?.innerText,
34
+ priceChange: change?.innerText,
35
+ priceChangeTone,
36
+ };
37
+ });
38
+ return {
39
+ route: location.hash,
40
+ title: document.title,
41
+ items: rows,
42
+ truncated: document.querySelectorAll('[data-testid="multichain-token-list-button"]').length > rows.length,
43
+ };
44
+ })()`);
45
+ return {
46
+ action: input.action,
47
+ ...normalizeVisibleAssetsState(raw, options, 'extension'),
48
+ proofPath: 'visible-dom-accessibility',
49
+ };
50
+ }));
@@ -0,0 +1,23 @@
1
+ import { runAdapter, withExtensionPage } from '../platform/cdp.mjs';
2
+ import { bodyState, selectors, visible, waitFor } from './send.mjs';
3
+
4
+ runAdapter(async (input) => withExtensionPage(input, async (page) => {
5
+ const timeoutMs = Number(input.node?.timeout_ms ?? 30_000);
6
+ await waitFor(page, () => visible(page, selectors.continue), 'Send Continue was not visible.', timeoutMs);
7
+ await page.click(selectors.continue);
8
+ const deadline = Date.now() + timeoutMs;
9
+ while (Date.now() < deadline && !(await visible(page, selectors.confirm))) {
10
+ if (await visible(page, selectors.alert)) await page.click(selectors.alert);
11
+ await new Promise((resolve) => setTimeout(resolve, 150));
12
+ }
13
+ await waitFor(page, () => visible(page, selectors.confirm), 'Send confirmation did not open.', timeoutMs);
14
+ const state = await bodyState(page);
15
+ if (!/\b(network fee|gas)\b/iu.test(state.text)) {
16
+ throw new Error('Send confirmation did not show a network fee.');
17
+ }
18
+ return {
19
+ action: input.action,
20
+ ...state,
21
+ proofPath: 'visible-dom-accessibility',
22
+ };
23
+ }));
@@ -0,0 +1,30 @@
1
+ import { dataTestId, runAdapter, withExtensionPage } from '../platform/cdp.mjs';
2
+ import { openHome } from '../ui/navigate.mjs';
3
+ import { visible, waitFor } from './send.mjs';
4
+
5
+ runAdapter(async (input) => withExtensionPage(input, async (page) => {
6
+ const scope = String(input.node?.scope || 'all').trim().toLowerCase();
7
+ const timeoutMs = Number(input.node?.timeout_ms ?? 30_000);
8
+ if (scope !== 'all') {
9
+ throw new Error('metamask.assets.select_network_scope currently requires scope=all.');
10
+ }
11
+ await openHome(page, timeoutMs);
12
+ const filter = dataTestId('sort-by-networks');
13
+ const all = dataTestId('home-network-filter-all-default');
14
+ 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);
24
+ return {
25
+ action: input.action,
26
+ scope,
27
+ selected,
28
+ proofPath: 'trusted-pointer-visible-ui',
29
+ };
30
+ }));
@@ -0,0 +1,104 @@
1
+ import { dataTestId } from '../platform/cdp.mjs';
2
+
3
+ export async function visible(page, selector) {
4
+ return page.evaluate(`(() => {
5
+ const element = document.querySelector(${JSON.stringify(selector)});
6
+ if (!element) return false;
7
+ const style = getComputedStyle(element);
8
+ const rect = element.getBoundingClientRect();
9
+ return style.display !== 'none' && style.visibility !== 'hidden' && rect.width > 0 && rect.height > 0;
10
+ })()`);
11
+ }
12
+
13
+ export async function waitFor(page, predicate, message, timeoutMs) {
14
+ const deadline = Date.now() + timeoutMs;
15
+ while (Date.now() < deadline) {
16
+ const value = await predicate();
17
+ if (value) return value;
18
+ await new Promise((resolve) => setTimeout(resolve, 150));
19
+ }
20
+ throw new Error(message);
21
+ }
22
+
23
+ export async function tokenRow(page, symbol) {
24
+ const marker = `mm-harness-${Date.now()}-${Math.random().toString(16).slice(2)}`;
25
+ return page.evaluate(`(() => {
26
+ const symbol = ${JSON.stringify(symbol.toUpperCase())};
27
+ const marker = ${JSON.stringify(marker)};
28
+ for (const row of document.querySelectorAll('[data-testid="multichain-token-list-button"]')) {
29
+ const amount = String(row.querySelector('[data-testid="multichain-token-list-item-value"]')?.innerText || '').replace(/\\s+/gu, ' ').trim();
30
+ const observed = amount.match(/\\b([A-Za-z][A-Za-z0-9]{1,11})$/u)?.[1]?.toUpperCase();
31
+ if (observed !== symbol) continue;
32
+ const style = getComputedStyle(row);
33
+ const rect = row.getBoundingClientRect();
34
+ if (style.display === 'none' || style.visibility === 'hidden' || rect.width <= 0 || rect.height <= 0) continue;
35
+ row.setAttribute('data-mm-harness-target', marker);
36
+ return {
37
+ selector: '[data-mm-harness-target="' + marker + '"]',
38
+ name: String(row.querySelector('[data-testid="multichain-token-list-item-token-name"]')?.innerText || '').trim(),
39
+ amount,
40
+ fiat: String(row.querySelector('[data-testid="multichain-token-list-item-secondary-value"]')?.innerText || '').trim(),
41
+ };
42
+ }
43
+ return null;
44
+ })()`);
45
+ }
46
+
47
+ export async function bodyState(page) {
48
+ return page.evaluate(`(() => ({
49
+ route: location.hash,
50
+ text: String(document.body?.innerText || '').replace(/\\s+/gu, ' ').trim().slice(0, 20_000),
51
+ }))()`);
52
+ }
53
+
54
+ export async function transactionState(page) {
55
+ return page.evaluate(`(async () => {
56
+ const hooks = globalThis.stateHooks || {};
57
+ const root = (await hooks.getCleanAppState?.()) || hooks.store?.getState?.() || null;
58
+ const transactions = root?.metamask?.transactions;
59
+ if (!Array.isArray(transactions)) return { available: false, transactions: [] };
60
+ return {
61
+ available: true,
62
+ transactions: transactions.slice(0, 500).map((transaction) => ({
63
+ id: transaction?.id == null ? null : String(transaction.id),
64
+ hash: transaction?.hash == null ? null : String(transaction.hash),
65
+ status: transaction?.status == null ? null : String(transaction.status),
66
+ chainId: transaction?.chainId == null ? null : String(transaction.chainId),
67
+ recipient: transaction?.txParams?.to == null ? null : String(transaction.txParams.to),
68
+ time: Number(transaction?.time ?? 0),
69
+ })).filter((transaction) => transaction.id),
70
+ };
71
+ })()`);
72
+ }
73
+
74
+ export async function activityItem(page, symbol, amount) {
75
+ const marker = `mm-harness-${Date.now()}-${Math.random().toString(16).slice(2)}`;
76
+ return page.evaluate(`(() => {
77
+ const symbol = ${JSON.stringify(symbol.toUpperCase())};
78
+ const amount = ${JSON.stringify(String(amount))};
79
+ const marker = ${JSON.stringify(marker)};
80
+ for (const row of document.querySelectorAll('[data-testid="activity-list-item"]')) {
81
+ const label = String(row.innerText || row.textContent || '').replace(/\\s+/gu, ' ').trim();
82
+ if (!label.toUpperCase().includes(symbol) || (amount.toLowerCase() !== 'max' && !label.includes(amount))) continue;
83
+ const style = getComputedStyle(row);
84
+ const rect = row.getBoundingClientRect();
85
+ if (style.display === 'none' || style.visibility === 'hidden' || rect.width <= 0 || rect.height <= 0) continue;
86
+ row.setAttribute('data-mm-harness-target', marker);
87
+ return { selector: '[data-mm-harness-target="' + marker + '"]', label: label.slice(0, 1000) };
88
+ }
89
+ return null;
90
+ })()`);
91
+ }
92
+
93
+ export const selectors = {
94
+ send: dataTestId('eth-overview-send'),
95
+ nativeSend: dataTestId('coin-overview-send'),
96
+ recipient: dataTestId('recipient-address-input'),
97
+ amount: dataTestId('send-amount-input'),
98
+ continue: dataTestId('send-continue-button'),
99
+ alert: dataTestId('send-alert-modal-acknowledge-button'),
100
+ confirm: dataTestId('confirm-footer-button'),
101
+ cancel: dataTestId('confirm-footer-cancel-button'),
102
+ activity: dataTestId('bottom-nav-activity'),
103
+ home: dataTestId('bottom-nav-home'),
104
+ };
@@ -0,0 +1,56 @@
1
+ import { dataTestId, runAdapter, withExtensionPage } from '../platform/cdp.mjs';
2
+ import { bodyState, visible, waitFor } from './send.mjs';
3
+
4
+ runAdapter(async (input) => withExtensionPage(input, async (page) => {
5
+ const gasLimit = String(input.node?.gas_limit || '').trim();
6
+ const timeoutMs = Number(input.node?.timeout_ms ?? 30_000);
7
+ if (!/^\d+$/u.test(gasLimit) || Number(gasLimit) <= 0) {
8
+ throw new Error('metamask.assets.set_custom_gas requires a positive integer gas_limit.');
9
+ }
10
+ const edit = dataTestId('edit-gas-fee-icon');
11
+ const advanced = dataTestId('gas-option-advanced');
12
+ const modal = dataTestId('gas-fee-advanced-eip1559-modal');
13
+ const gasInput = '#gas-input';
14
+ const maxFeeInput = '#max-base-fee-input';
15
+ const priorityFeeInput = '#priority-fee-input';
16
+ const save = dataTestId('gas-fee-modal-save-button');
17
+ if (!(await visible(page, modal))) {
18
+ await waitFor(page, async () => {
19
+ if (await visible(page, modal)) return true;
20
+ const control = (await visible(page, advanced)) ? advanced : (await visible(page, edit)) ? edit : null;
21
+ if (control) {
22
+ try {
23
+ await page.click(control);
24
+ } catch {
25
+ return null;
26
+ }
27
+ }
28
+ return null;
29
+ }, 'Advanced gas editor did not become visible.', timeoutMs);
30
+ }
31
+ await waitFor(page, () => visible(page, gasInput), 'Gas limit input did not become visible.', timeoutMs);
32
+ const fees = await page.evaluate(`(() => ({
33
+ maxFee: document.querySelector(${JSON.stringify(maxFeeInput)})?.value || '',
34
+ priorityFee: document.querySelector(${JSON.stringify(priorityFeeInput)})?.value || '',
35
+ }))()`);
36
+ if (!(Number(fees.priorityFee) > 0)) {
37
+ const maxFee = Number(fees.maxFee);
38
+ if (!(maxFee > 0)) throw new Error('Advanced gas editor did not expose a positive max base fee.');
39
+ await page.setInput(priorityFeeInput, String(Math.min(maxFee / 2, 0.01)));
40
+ }
41
+ await page.setInput(gasInput, gasLimit);
42
+ await waitFor(page, () => page.evaluate(`document.querySelector(${JSON.stringify(save)})?.disabled === false`), 'Custom gas values did not enable Save.', timeoutMs);
43
+ await page.click(save);
44
+ const state = await waitFor(page, async () => {
45
+ const current = await bodyState(page);
46
+ return !(await visible(page, modal)) && /Speed Advanced/u.test(current.text) ? current : null;
47
+ }, 'Custom gas was not applied to the confirmation.', timeoutMs);
48
+ return {
49
+ action: input.action,
50
+ gasLimit,
51
+ maxFeeGwei: fees.maxFee,
52
+ priorityFeeGwei: Number(fees.priorityFee) > 0 ? fees.priorityFee : String(Math.min(Number(fees.maxFee) / 2, 0.01)),
53
+ ...state,
54
+ proofPath: 'trusted-pointer-visible-ui',
55
+ };
56
+ }));