@deeeed/metamask-harness 0.38.0 → 0.39.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 (49) hide show
  1. package/CHANGELOG.md +18 -0
  2. package/dist/adapters.js +20 -11
  3. package/library/actions/extension/assets/open_details.mjs +30 -0
  4. package/library/actions/extension/assets/set_token_visibility.mjs +5 -0
  5. package/library/actions/extension/perps/open_balance_action.mjs +1 -6
  6. package/library/actions/extension/perps/search_markets.mjs +32 -0
  7. package/library/actions/extension/perps/select_market_filter.mjs +3 -2
  8. package/library/actions/extension/perps/set_market_favorite.mjs +6 -1
  9. package/library/actions/extension/swap_bridge/read_visible_state.mjs +2 -6
  10. package/library/actions/extension/swap_bridge/set_amount.mjs +11 -2
  11. package/library/actions/extension/swap_bridge/submit_transaction.mjs +11 -11
  12. package/library/actions/mobile/assets/import_custom_token.mjs +181 -0
  13. package/library/actions/mobile/assets/open_details.mjs +43 -0
  14. package/library/actions/mobile/assets/read_details.mjs +96 -0
  15. package/library/actions/mobile/assets/set_token_visibility.mjs +302 -0
  16. package/library/actions/mobile/assets/verify_sorting.mjs +104 -0
  17. package/library/actions/mobile/networks/add_custom.mjs +125 -0
  18. package/library/actions/mobile/networks/network-management.mjs +132 -0
  19. package/library/actions/mobile/networks/read_visible_state.mjs +43 -0
  20. package/library/actions/mobile/networks/remove_custom.mjs +100 -0
  21. package/library/actions/mobile/perps/search_markets.mjs +46 -0
  22. package/library/actions/mobile/perps/set_market_favorite.mjs +117 -0
  23. package/library/actions/mobile/platform/native-session-name.mjs +14 -0
  24. package/library/actions/mobile/platform/native-session.mjs +59 -0
  25. package/library/actions/mobile/platform/observe-ui.mjs +9 -1
  26. package/library/actions/mobile/swap_bridge/native-session.mjs +10 -45
  27. package/library/actions/mobile/swap_bridge/select_assets.mjs +15 -1
  28. package/library/actions/mobile/swap_bridge/set_amount.mjs +1 -1
  29. package/library/actions/mobile/swap_bridge/set_max_amount.mjs +55 -0
  30. package/library/actions/mobile/swap_bridge/set_slippage.mjs +96 -0
  31. package/library/actions/mobile/swap_bridge/submit_transaction.mjs +141 -0
  32. package/library/actions/mobile/ui/native-navigation.mjs +75 -23
  33. package/library/actions/mobile/ui/navigate.mjs +13 -2
  34. package/library/actions/mobile/wallet/lock.mjs +14 -0
  35. package/library/actions/mobile/wallet/native-ui.mjs +67 -14
  36. package/library/actions/shared/perps/visible-state.mjs +8 -6
  37. package/library/actions/shared/swap-bridge/visible-state.mjs +15 -0
  38. package/library/manifests/extension.action-manifest.json +43 -2
  39. package/library/manifests/mobile.action-manifest.json +308 -1
  40. package/library/recipes/assets/release-token-details.recipe.json +5 -4
  41. package/library/recipes/assets/release-token-hide-readd.recipe.json +2 -0
  42. package/library/recipes/mobile/networks/release-custom-network-persistence.recipe.json +90 -0
  43. package/library/recipes/mobile/networks/release-multichain-visibility.recipe.json +58 -0
  44. package/library/recipes/perps/release-market-search.recipe.json +8 -4
  45. package/library/recipes/perps/release-watchlist.recipe.json +31 -19
  46. package/library/recipes/swap-bridge/release-custom-slippage.recipe.json +5 -13
  47. package/package.json +1 -1
  48. package/scripts/validate-release-report.mjs +28 -14
  49. /package/library/recipes/{assets → extension/assets}/release-custom-network-token-persistence.recipe.json +0 -0
package/CHANGELOG.md CHANGED
@@ -2,6 +2,24 @@
2
2
 
3
3
  ## Unreleased
4
4
 
5
+ ## 0.39.0 - 2026-08-14
6
+
7
+ ### Added
8
+
9
+ - Add opaque Android release actions for token details, sorting, visibility, custom-token import, custom-network lifecycle, Perps search and watchlists, Swap Max, slippage, guarded submission, and wallet locking through native accessibility.
10
+ - Make shared Assets, Perps, and Swap release recipes executable on untouched Extension and Android release artifacts with parameterized visible-state assertions.
11
+
12
+ ### Fixed
13
+
14
+ - Keep physical-device actions on isolated semantic sessions, bypass biometric authentication with password unlock, verify every recovery-phrase field, and reject incorrect token metadata as a product failure.
15
+ - Prove Extension token visibility changes and parse visible custom-slippage labels consistently across both clients.
16
+
17
+ ## 0.38.1 - 2026-08-14
18
+
19
+ ### Fixed
20
+
21
+ - Read release evidence through verified regular-file descriptors, escape generated Markdown safely, and keep UI waits out of dynamically constructed page expressions.
22
+
5
23
  ## 0.38.0 - 2026-08-14
6
24
 
7
25
  ### Added
package/dist/adapters.js CHANGED
@@ -8,6 +8,7 @@ import {
8
8
  import { withExtensionPage } from "../library/actions/extension/platform/cdp.mjs";
9
9
  import { bridgeCommand, evalAsync, evalSync, MOBILE_BRIDGE_ERROR_CODES, selectBridgeStatusEntry, simulatorScreenshot } from "../library/actions/mobile/platform/bridge.mjs";
10
10
  import { observeNativeUi } from "../library/actions/mobile/platform/observe-ui.mjs";
11
+ import { nativeAgentDeviceStateDir } from "../library/actions/mobile/platform/native-session-name.mjs";
11
12
  import { resolveMobileToolPath } from "../library/actions/mobile/platform/tool-paths.mjs";
12
13
  import { resolveWalletImportCredentials, validateWalletImportOptions } from "../library/actions/shared/wallet/import-source.mjs";
13
14
  const execFileAsync = promisify(execFile);
@@ -37,6 +38,7 @@ function simpleAdapter(action, executor) {
37
38
  }
38
39
  const LIVE_ONLY_ACTIONS = /* @__PURE__ */ new Set([
39
40
  "metamask.assets.read_visible_state",
41
+ "metamask.assets.open_details",
40
42
  "metamask.assets.read_details",
41
43
  "metamask.assets.verify_sorting",
42
44
  "metamask.assets.set_token_visibility",
@@ -52,6 +54,7 @@ const LIVE_ONLY_ACTIONS = /* @__PURE__ */ new Set([
52
54
  "metamask.networks.add_chainlist",
53
55
  "metamask.networks.remove_custom",
54
56
  "metamask.perps.read_visible_state",
57
+ "metamask.perps.search_markets",
55
58
  "metamask.perps.compare_provider_market",
56
59
  "metamask.perps.read_eligibility",
57
60
  "metamask.perps.select_market_filter",
@@ -250,10 +253,11 @@ function createMetaMaskSemanticAdapters(platform, declaredCustomActions = [], pr
250
253
  const bundledActions = [
251
254
  ...walletActions,
252
255
  "metamask.assets.read_visible_state",
253
- ...platform === "extension" ? ["metamask.assets.read_details"] : [],
254
- ...platform === "extension" ? ["metamask.assets.verify_sorting"] : [],
255
- ...platform === "extension" ? ["metamask.assets.set_token_visibility"] : [],
256
- ...platform === "extension" ? ["metamask.assets.import_custom_token"] : [],
256
+ "metamask.assets.open_details",
257
+ "metamask.assets.read_details",
258
+ "metamask.assets.verify_sorting",
259
+ "metamask.assets.set_token_visibility",
260
+ "metamask.assets.import_custom_token",
257
261
  ...platform === "extension" ? [
258
262
  "metamask.assets.prepare_send",
259
263
  "metamask.assets.review_send",
@@ -261,10 +265,14 @@ function createMetaMaskSemanticAdapters(platform, declaredCustomActions = [], pr
261
265
  "metamask.assets.set_custom_gas",
262
266
  "metamask.assets.select_network_scope"
263
267
  ] : [],
264
- ...platform === "extension" ? ["metamask.wallet.lock"] : [],
265
- ...platform === "extension" ? ["metamask.networks.read_visible_state"] : [],
266
- ...platform === "extension" ? ["metamask.networks.add_custom", "metamask.networks.add_chainlist", "metamask.networks.remove_custom"] : [],
268
+ "metamask.wallet.lock",
269
+ "metamask.networks.read_visible_state",
270
+ "metamask.networks.add_custom",
271
+ ...platform === "extension" ? ["metamask.networks.add_chainlist"] : [],
272
+ "metamask.networks.remove_custom",
267
273
  "metamask.perps.read_visible_state",
274
+ "metamask.perps.search_markets",
275
+ "metamask.perps.set_market_favorite",
268
276
  ...platform === "extension" ? ["metamask.perps.compare_provider_market"] : [],
269
277
  ...platform === "extension" ? ["metamask.perps.read_eligibility"] : [],
270
278
  ...platform === "extension" ? [
@@ -275,9 +283,9 @@ function createMetaMaskSemanticAdapters(platform, declaredCustomActions = [], pr
275
283
  "metamask.swap_bridge.read_visible_state",
276
284
  "metamask.swap_bridge.select_assets",
277
285
  "metamask.swap_bridge.set_amount",
278
- ...platform === "extension" ? ["metamask.swap_bridge.set_max_amount"] : [],
279
- ...platform === "extension" ? ["metamask.swap_bridge.set_slippage"] : [],
280
- ...platform === "extension" ? ["metamask.swap_bridge.submit_transaction"] : [],
286
+ "metamask.swap_bridge.set_max_amount",
287
+ "metamask.swap_bridge.set_slippage",
288
+ "metamask.swap_bridge.submit_transaction",
281
289
  ...platform === "extension" ? ["metamask.settings.set_basic_functionality"] : [],
282
290
  "metamask.perps.read_positions",
283
291
  "metamask.perps.ensure_positions",
@@ -900,9 +908,10 @@ async function executeNativeProviderAction(action, node, context, createTranspor
900
908
  }
901
909
  const app = platform === "android" ? context.env.ANDROID_PACKAGE_ID ?? process.env.ANDROID_PACKAGE_ID ?? "io.metamask" : context.env.IOS_BUNDLE_ID ?? process.env.IOS_BUNDLE_ID ?? "io.metamask.MetaMask";
902
910
  const session = `mm-harness-${process.pid}-${context.nodeId}`.replace(/[^a-zA-Z0-9._-]/gu, "-");
911
+ const stateDir = nativeAgentDeviceStateDir(context.env ?? {}, context.projectRoot, String(device));
903
912
  let transport;
904
913
  try {
905
- transport = await createTransport({ platform, device, app, session });
914
+ transport = await createTransport({ platform, device, app, session, stateDir });
906
915
  } catch (error) {
907
916
  throw new Error(
908
917
  `${action} could not start the MetaMask native UI provider. Next: npm install -g @deeeed/metamask-harness@latest`,
@@ -0,0 +1,30 @@
1
+ import { runAdapter, withExtensionPage } from '../platform/cdp.mjs';
2
+
3
+ runAdapter(async (input) => withExtensionPage(input, async (page) => {
4
+ const symbol = String(input.node?.symbol ?? '').trim().toUpperCase();
5
+ const timeoutMs = Number(input.node?.timeout_ms ?? 30_000);
6
+ if (!/^[A-Z0-9._-]{2,20}$/u.test(symbol)) {
7
+ throw new Error('metamask.assets.open_details requires a valid token symbol.');
8
+ }
9
+ const selector = await page.evaluate(`(() => {
10
+ const symbol = ${JSON.stringify(symbol)};
11
+ for (const [index, row] of [...document.querySelectorAll('[data-testid="multichain-token-list-button"]')].entries()) {
12
+ const amount = String(row.querySelector('[data-testid="multichain-token-list-item-value"]')?.innerText || '').trim();
13
+ const observed = amount.match(/\\b([A-Za-z][A-Za-z0-9._-]{1,19})$/u)?.[1]?.toUpperCase();
14
+ if (observed !== symbol) continue;
15
+ row.setAttribute('data-mm-harness-asset-index', String(index));
16
+ return '[data-mm-harness-asset-index="' + index + '"]';
17
+ }
18
+ return null;
19
+ })()`);
20
+ if (!selector) throw new Error(`Visible asset ${symbol} was not found.`);
21
+ await page.click(selector);
22
+ const deadline = Date.now() + timeoutMs;
23
+ while (Date.now() <= deadline) {
24
+ if (String(await page.evaluate('location.hash')).startsWith('#/asset/')) {
25
+ return { action: input.action, symbol, proofPath: 'trusted-pointer-visible-ui' };
26
+ }
27
+ await page.evaluate('new Promise((resolve) => setTimeout(resolve, 100))');
28
+ }
29
+ throw new Error(`Asset details for ${symbol} did not open.`);
30
+ }));
@@ -75,6 +75,7 @@ runAdapter(async (input) => withExtensionPage(input, async (page) => {
75
75
  }
76
76
  const requested = input.node.visible;
77
77
  const requireUnchanged = input.node?.require_unchanged === true;
78
+ const requireChange = input.node?.require_change === true;
78
79
  const timeoutMs = Number(input.node?.timeout_ms ?? 45_000);
79
80
 
80
81
  if (!(await visible(page, MANAGE))) await page.click(OPTIONS);
@@ -91,6 +92,9 @@ runAdapter(async (input) => withExtensionPage(input, async (page) => {
91
92
  if (requireUnchanged && before.enabled !== requested) {
92
93
  throw new Error(`${query} token visibility did not persist as ${requested ? 'enabled' : 'disabled'}.`);
93
94
  }
95
+ if (requireChange && before.enabled === requested) {
96
+ throw new Error(`${query} token visibility was already ${requested ? 'enabled' : 'disabled'}.`);
97
+ }
94
98
  if (before.enabled !== requested) {
95
99
  await page.click(dataTestId(before.testId));
96
100
  }
@@ -120,6 +124,7 @@ runAdapter(async (input) => withExtensionPage(input, async (page) => {
120
124
  query,
121
125
  requested,
122
126
  requireUnchanged,
127
+ requireChange,
123
128
  changed: before.enabled !== after.enabled,
124
129
  searchResult: after.label,
125
130
  toggleTestId: after.testId,
@@ -13,12 +13,7 @@ runAdapter(async (input) => withExtensionPage(input, async (page) => {
13
13
  }
14
14
  await page.click(dataTestId('perps-balance-dropdown-balance'));
15
15
  const actionSelector = dataTestId(actionTestId);
16
- const deadline = Date.now() + 10_000;
17
- while (Date.now() <= deadline) {
18
- const present = await page.evaluate(`(() => Boolean(document.querySelector(${JSON.stringify(actionSelector)})))()`);
19
- if (present) break;
20
- await new Promise((resolve) => setTimeout(resolve, 100));
21
- }
16
+ await page.waitForSelector(actionSelector, { timeoutMs: 10_000 });
22
17
  await page.click(actionSelector);
23
18
  const expected = balanceAction === 'withdraw'
24
19
  ? ['perps-withdraw-page', 'perps-withdraw-container', 'perps-withdraw-view']
@@ -0,0 +1,32 @@
1
+ import { dataTestId, runAdapter, withExtensionPage } from '../platform/cdp.mjs';
2
+
3
+ runAdapter(async (input) => withExtensionPage(input, async (page) => {
4
+ const query = String(input.node?.query ?? '').trim();
5
+ const timeoutMs = Number(input.node?.timeout_ms ?? 30_000);
6
+ if (!query) throw new Error('metamask.perps.search_markets requires a non-empty query.');
7
+ await page.setInput(dataTestId('search-input'), query);
8
+ const deadline = Date.now() + timeoutMs;
9
+ let marketIds = [];
10
+ while (Date.now() <= deadline) {
11
+ marketIds = await page.evaluate(`(() => [...document.querySelectorAll('[data-testid^="market-row-"]')]
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
+ .map((element) => String(element.getAttribute('data-testid') || ''))
18
+ .filter(Boolean)
19
+ .slice(0, 100))()`);
20
+ if (marketIds.length > 0) break;
21
+ await page.evaluate('new Promise((resolve) => setTimeout(resolve, 200))');
22
+ }
23
+ if (marketIds.length === 0) {
24
+ throw new Error(`Perps market search returned no visible rows for ${JSON.stringify(query)}.`);
25
+ }
26
+ return {
27
+ action: input.action,
28
+ query,
29
+ marketIds,
30
+ proofPath: 'trusted-keyboard-visible-ui',
31
+ };
32
+ }));
@@ -31,8 +31,9 @@ runAdapter(async (input) => withExtensionPage(input, async (page) => {
31
31
  });
32
32
  await page.click(dataTestId('filter-select-button'));
33
33
  const optionSelector = dataTestId(`filter-select-option-${option}`);
34
- const found = await page.evaluate(`(() => Boolean(document.querySelector(${JSON.stringify(optionSelector)})))()`);
35
- if (!found) {
34
+ try {
35
+ await page.waitForSelector(optionSelector, { timeoutMs: 2_000 });
36
+ } catch {
36
37
  throw new Error(`Perps market filter ${JSON.stringify(filter)} is not available in this release.`);
37
38
  }
38
39
  await page.click(optionSelector);
@@ -9,6 +9,7 @@ runAdapter(async (input) => withExtensionPage(input, async (page) => {
9
9
  throw new Error('metamask.perps.set_market_favorite requires favorite=true or false.');
10
10
  }
11
11
  const desired = input.node.favorite;
12
+ const requireUnchanged = input.node?.require_unchanged === true;
12
13
  const selector = dataTestId('perps-market-detail-favorite-button');
13
14
  const readState = () => page.evaluate(`(() => {
14
15
  const market = ${JSON.stringify(market)};
@@ -35,11 +36,15 @@ runAdapter(async (input) => withExtensionPage(input, async (page) => {
35
36
  if (after.favorite !== desired) {
36
37
  throw new Error(`Perps favorite state did not become ${desired}.`);
37
38
  }
39
+ const changed = before.favorite !== after.favorite;
40
+ if (requireUnchanged && changed) {
41
+ throw new Error(`Perps favorite state for ${market} did not persist across navigation.`);
42
+ }
38
43
  return {
39
44
  action: input.action,
40
45
  market,
41
46
  requestedFavorite: desired,
42
- changed: before.favorite !== after.favorite,
47
+ changed,
43
48
  before,
44
49
  after,
45
50
  proofPath: 'trusted-pointer-visible-ui',
@@ -13,6 +13,7 @@ runAdapter(async (input) => withExtensionPage(input, async (page) => {
13
13
  'bridge-source-button',
14
14
  'bridge-destination-button',
15
15
  'bridge__header-settings-button',
16
+ 'slippage-edit-button',
16
17
  'prepare-swap-page-error',
17
18
  'bridge-missing-price-banner',
18
19
  ];
@@ -39,6 +40,7 @@ runAdapter(async (input) => withExtensionPage(input, async (page) => {
39
40
  route: location.hash,
40
41
  title: document.title,
41
42
  items,
43
+ slippage: String(slippageButton?.parentElement?.innerText || '').replace(/\\s+/gu, ' ').trim() || null,
42
44
  quote: {
43
45
  slippage: String(slippageButton?.parentElement?.innerText || '').replace(/\\s+/gu, ' ').trim() || null,
44
46
  networkFee: text('[data-testid="network-fees-sponsored"], [data-testid="network-fees-included"], [data-testid="network-fees"]') || null,
@@ -52,16 +54,10 @@ runAdapter(async (input) => withExtensionPage(input, async (page) => {
52
54
  })()`);
53
55
  const state = normalizeVisibleSwapBridgeState(raw, options, 'extension');
54
56
  const requireQuote = input.node?.require_quote === true;
55
- const expectedSlippage = input.node?.expected_slippage == null
56
- ? null
57
- : Number(input.node.expected_slippage);
58
57
  if (requireQuote && (!raw.quote.ctaEnabled || !(Number(state.destinationAmount) > 0)
59
58
  || !/^(?:Swap|Bridge)$/iu.test(String(raw.quote.cta)))) {
60
59
  throw new Error('Swap quote is not ready for review.');
61
60
  }
62
- if (expectedSlippage !== null && raw.quote.slippage !== `${expectedSlippage}%`) {
63
- throw new Error(`Swap slippage mismatch: expected ${expectedSlippage}%, observed ${raw.quote.slippage ?? 'none'}.`);
64
- }
65
61
  return {
66
62
  action: input.action,
67
63
  ...state,
@@ -7,6 +7,7 @@ runAdapter(async (input) => withExtensionPage(input, async (page) => {
7
7
  await page.setInput(source, options.amount);
8
8
  const deadline = Date.now() + options.timeoutMs;
9
9
  let observed;
10
+ let lastRefreshAt = 0;
10
11
  do {
11
12
  observed = await page.evaluate(`(() => {
12
13
  const value = (testId) => {
@@ -23,13 +24,21 @@ runAdapter(async (input) => withExtensionPage(input, async (page) => {
23
24
  noQuote: document.body.innerText.includes("This trade route isn't available right now"),
24
25
  };
25
26
  })()`);
26
- if (!options.waitForQuote || observed.noQuote || (Number(observed.destinationAmount) > 0 && observed.cta && observed.ctaEnabled)) break;
27
+ if (!options.waitForQuote || observed.noQuote) break;
28
+ if (/^(?:Get new quote|Refresh quote)$/iu.test(String(observed.cta))
29
+ && observed.ctaEnabled && Date.now() - lastRefreshAt >= 1000) {
30
+ await page.click(dataTestId('bridge-cta-button'));
31
+ lastRefreshAt = Date.now();
32
+ }
33
+ if (Number(observed.destinationAmount) > 0 && observed.ctaEnabled
34
+ && /^(?:Swap|Bridge)$/iu.test(String(observed.cta))) break;
27
35
  await page.evaluate('new Promise((resolve) => setTimeout(resolve, 250))');
28
36
  } while (Date.now() < deadline);
29
37
  if (observed.sourceAmount !== options.amount) {
30
38
  throw new Error(`Swap source amount mismatch: expected ${options.amount}, observed ${observed.sourceAmount}.`);
31
39
  }
32
- if (options.waitForQuote && !observed.noQuote && !(Number(observed.destinationAmount) > 0 && observed.cta && observed.ctaEnabled)) {
40
+ if (options.waitForQuote && !observed.noQuote && !(Number(observed.destinationAmount) > 0
41
+ && observed.ctaEnabled && /^(?:Swap|Bridge)$/iu.test(String(observed.cta)))) {
33
42
  throw new Error(`Swap quote did not resolve within ${options.timeoutMs}ms.`);
34
43
  }
35
44
  return { action: input.action, ...observed, proofPath: 'visible-ui-amount' };
@@ -11,8 +11,8 @@ const selectors = {
11
11
  swap: dataTestId('bottom-nav-swaps'),
12
12
  };
13
13
 
14
- async function pause(page, timeoutMs = 200) {
15
- await page.evaluate(`new Promise((resolve) => setTimeout(resolve, ${JSON.stringify(timeoutMs)}))`);
14
+ async function pause(timeoutMs = 200) {
15
+ await new Promise((resolve) => setTimeout(resolve, timeoutMs));
16
16
  }
17
17
 
18
18
  async function activatePoint(page, point) {
@@ -66,14 +66,14 @@ async function openActivity(page, timeoutMs) {
66
66
  if (isActivityRoute(state.route) && await visible(page, selectors.swap) && Date.now() - lastResetAt >= 1000) {
67
67
  await activateSelector(page, selectors.swap);
68
68
  lastResetAt = Date.now();
69
- await pause(page);
69
+ await pause();
70
70
  continue;
71
71
  }
72
72
  if (await visible(page, selectors.activity) && Date.now() - lastActivationAt >= 1000) {
73
73
  await activateSelector(page, selectors.activity);
74
74
  lastActivationAt = Date.now();
75
75
  }
76
- await pause(page);
76
+ await pause();
77
77
  }
78
78
  throw new Error('Activity did not open after swap.');
79
79
  }
@@ -107,11 +107,11 @@ async function submittableQuote(page, timeoutMs) {
107
107
  const quote = await quoteState(page);
108
108
  if (/^(?:Get new quote|Refresh quote)$/iu.test(String(quote.cta)) && quote.ctaEnabled) {
109
109
  await page.click(selectors.cta);
110
- await pause(page, 500);
110
+ await pause(500);
111
111
  continue;
112
112
  }
113
113
  if (/^(?:Swap|Bridge)$/iu.test(String(quote.cta)) && quote.ctaEnabled && !quote.loading) return quote;
114
- await pause(page);
114
+ await pause();
115
115
  }
116
116
  throw new Error('Swap did not expose a fresh submittable quote.');
117
117
  }
@@ -186,7 +186,7 @@ runAdapter(async (input) => withExtensionPage(input, async (page) => {
186
186
  || quote.warning) {
187
187
  throw new Error(`Swap quote is not safe to submit: ${JSON.stringify(quote)}.`);
188
188
  }
189
- await pause(page, 2000);
189
+ await pause(2000);
190
190
  quote = await submittableQuote(page, Math.min(timeoutMs, 60_000));
191
191
  if (!quote.sourceSymbol.toUpperCase().includes(sourceSymbol)
192
192
  || !quote.destinationSymbol.toUpperCase().includes(destinationSymbol)
@@ -210,14 +210,14 @@ runAdapter(async (input) => withExtensionPage(input, async (page) => {
210
210
  while (Date.now() < submissionDeadline) {
211
211
  if (await visible(page, selectors.warningProceed)) {
212
212
  await page.click(selectors.warningProceed);
213
- await pause(page);
213
+ await pause();
214
214
  continue;
215
215
  }
216
216
  if (await visible(page, selectors.confirm)) {
217
217
  await page.click(selectors.confirm);
218
218
  confirmed = true;
219
219
  submitted = true;
220
- await pause(page);
220
+ await pause();
221
221
  continue;
222
222
  }
223
223
  const state = await bodyState(page);
@@ -228,7 +228,7 @@ runAdapter(async (input) => withExtensionPage(input, async (page) => {
228
228
  if (submitted && (state.route === '' || state.route === '#/' || isActivityRoute(state.route) || /Processing|Transaction complete|submitted/iu.test(state.text))) {
229
229
  break;
230
230
  }
231
- await pause(page);
231
+ await pause();
232
232
  }
233
233
  if (!submitted) {
234
234
  throw new Error('Swap submission did not reach a signed or submitted state.');
@@ -259,7 +259,7 @@ runAdapter(async (input) => withExtensionPage(input, async (page) => {
259
259
  if (row?.status === 'confirmed') return row;
260
260
  if (Date.now() - lastActivityRefresh >= 5000 && await visible(page, selectors.home)) {
261
261
  await page.click(selectors.home);
262
- await pause(page, 500);
262
+ await pause(500);
263
263
  await openActivity(page, Math.min(10_000, Math.max(1, timeoutMs)));
264
264
  lastActivityRefresh = Date.now();
265
265
  }
@@ -0,0 +1,181 @@
1
+ import { runAdapter } from '../platform/bridge.mjs';
2
+ import {
3
+ closeNativeSession,
4
+ createNativeSession,
5
+ nativeLabel,
6
+ nativeNode,
7
+ } from '../platform/native-session.mjs';
8
+
9
+ async function waitForSnapshot(session, timeoutMs, predicate, message) {
10
+ const deadline = Date.now() + timeoutMs;
11
+ while (Date.now() <= deadline) {
12
+ const snapshot = await session.snapshot();
13
+ const value = predicate(snapshot);
14
+ if (value) return { snapshot, value };
15
+ await new Promise((resolve) => setTimeout(resolve, 200));
16
+ }
17
+ throw new Error(message);
18
+ }
19
+
20
+ async function openCustomToken(session, timeoutMs) {
21
+ let snapshot = await session.snapshot();
22
+ if (nativeNode(snapshot, 'input-token-address')) return snapshot;
23
+ if (!nativeNode(snapshot, 'add-token-screen')) {
24
+ if (!nativeNode(snapshot, 'token-list')) {
25
+ await session.execute('ui.press', {
26
+ test_id: 'homepage-section-title-tokens',
27
+ timeout_ms: timeoutMs,
28
+ settle: false,
29
+ });
30
+ await waitForSnapshot(
31
+ session,
32
+ timeoutMs,
33
+ (current) => nativeNode(current, 'token-list'),
34
+ 'The Mobile token list did not open.',
35
+ );
36
+ }
37
+ await session.execute('ui.press', {
38
+ test_id: 'import-token-button',
39
+ timeout_ms: timeoutMs,
40
+ settle: false,
41
+ });
42
+ snapshot = (await waitForSnapshot(
43
+ session,
44
+ timeoutMs,
45
+ (current) => nativeNode(current, 'add-token-screen'),
46
+ 'The Mobile token import screen did not open.',
47
+ )).snapshot;
48
+ }
49
+ if (!nativeNode(snapshot, 'input-token-address')) {
50
+ await session.execute('ui.press', { text: 'Custom token', timeout_ms: timeoutMs, settle: false });
51
+ snapshot = (await waitForSnapshot(
52
+ session,
53
+ timeoutMs,
54
+ (current) => nativeNode(current, 'input-token-address'),
55
+ 'The Mobile custom-token form did not open.',
56
+ )).snapshot;
57
+ }
58
+ return snapshot;
59
+ }
60
+
61
+ async function selectNetwork(session, network, timeoutMs) {
62
+ let snapshot = await session.snapshot();
63
+ const selected = (current) =>
64
+ nativeNode(current, 'select-network-button') &&
65
+ current.nodes.some((node) => node.visibleToUser !== false && nativeLabel(node) === network);
66
+ if (selected(snapshot)) return;
67
+ await session.execute('ui.press', {
68
+ test_id: 'select-network-button',
69
+ timeout_ms: timeoutMs,
70
+ settle: false,
71
+ });
72
+ await waitForSnapshot(
73
+ session,
74
+ timeoutMs,
75
+ (current) => current.nodes.find((node) =>
76
+ node.visibleToUser !== false && node.hittable === true && nativeLabel(node) === network
77
+ ),
78
+ `${network} was not available in the Mobile token network selector.`,
79
+ );
80
+ await session.execute('ui.press', { text: network, timeout_ms: timeoutMs, settle: false });
81
+ snapshot = (await waitForSnapshot(
82
+ session,
83
+ timeoutMs,
84
+ selected,
85
+ `The Mobile token importer did not select ${network}.`,
86
+ )).snapshot;
87
+ return snapshot;
88
+ }
89
+
90
+ async function findTokenRow(session, symbol, timeoutMs) {
91
+ for (let attempt = 0; attempt < 8; attempt += 1) {
92
+ const snapshot = await session.snapshot();
93
+ const row = nativeNode(snapshot, `asset-${symbol}`);
94
+ if (row) return row;
95
+ await session.execute('ui.scroll', {
96
+ direction: 'down',
97
+ amount: 0.75,
98
+ timeout_ms: timeoutMs,
99
+ settle: false,
100
+ });
101
+ }
102
+ throw new Error(`${symbol} did not appear in the Mobile token list after import.`);
103
+ }
104
+
105
+ runAdapter(async (input) => {
106
+ const network = String(input.node?.network ?? '').trim();
107
+ const chainId = Number(input.node?.chain_id);
108
+ const address = String(input.node?.address ?? '').trim();
109
+ const expectedSymbol = String(input.node?.expected_symbol ?? '').trim().toUpperCase();
110
+ const expectedDecimals = input.node?.decimals == null ? null : String(input.node.decimals);
111
+ const timeoutMs = Number(input.node?.timeout_ms ?? 60_000);
112
+ if (!network || !Number.isInteger(chainId) || chainId <= 0 || !/^0x[0-9a-f]{40}$/iu.test(address) || !expectedSymbol) {
113
+ throw new Error('metamask.assets.import_custom_token requires network, positive chain_id, ERC-20 address, and expected_symbol.');
114
+ }
115
+
116
+ const session = createNativeSession(input, 'Assets', `custom-token-${process.pid}`);
117
+ try {
118
+ await session.open();
119
+ await openCustomToken(session, timeoutMs);
120
+ await selectNetwork(session, network, timeoutMs);
121
+ await openCustomToken(session, timeoutMs);
122
+ await session.execute('ui.set_input', {
123
+ test_id: 'input-token-address',
124
+ value: address,
125
+ timeout_ms: timeoutMs,
126
+ settle: false,
127
+ });
128
+ const metadata = (await waitForSnapshot(
129
+ session,
130
+ Math.min(timeoutMs, 20_000),
131
+ (snapshot) => {
132
+ const symbol = nativeLabel(nativeNode(snapshot, 'input-token-symbol'));
133
+ const decimals = nativeLabel(nativeNode(snapshot, 'input-token-decimal'));
134
+ return symbol ? { symbol, decimals } : null;
135
+ },
136
+ 'Mobile custom-token metadata did not resolve.',
137
+ )).value;
138
+ if (metadata.symbol.toUpperCase() !== expectedSymbol) {
139
+ throw new Error(`Expected token symbol ${expectedSymbol}, but Mobile metadata resolved ${metadata.symbol}.`);
140
+ }
141
+ if (expectedDecimals !== null && metadata.decimals !== expectedDecimals) {
142
+ throw new Error(`Expected token decimals ${expectedDecimals}, but Mobile metadata resolved ${metadata.decimals}.`);
143
+ }
144
+ await waitForSnapshot(
145
+ session,
146
+ timeoutMs,
147
+ (snapshot) => nativeNode(snapshot, 'token-import-next-button')?.enabled === true,
148
+ 'Mobile custom-token metadata did not enable the import action.',
149
+ );
150
+ await session.execute('ui.press', {
151
+ test_id: 'token-import-next-button',
152
+ timeout_ms: timeoutMs,
153
+ settle: false,
154
+ });
155
+ await waitForSnapshot(
156
+ session,
157
+ timeoutMs,
158
+ (snapshot) => nativeNode(snapshot, 'add-confirm-custom-asset'),
159
+ 'The Mobile custom-token confirmation did not open.',
160
+ );
161
+ await session.execute('ui.press', { text: 'Import', timeout_ms: timeoutMs, settle: false });
162
+ await waitForSnapshot(
163
+ session,
164
+ timeoutMs,
165
+ (snapshot) => nativeNode(snapshot, 'token-list'),
166
+ 'The Mobile token list did not reopen after import.',
167
+ );
168
+ const token = await findTokenRow(session, expectedSymbol, timeoutMs);
169
+ return {
170
+ action: input.action,
171
+ network,
172
+ chainId,
173
+ address,
174
+ metadata,
175
+ token: nativeLabel(token),
176
+ proofPath: 'native-accessibility-custom-token-import',
177
+ };
178
+ } finally {
179
+ await closeNativeSession(session);
180
+ }
181
+ });
@@ -0,0 +1,43 @@
1
+ import { runAdapter } from '../platform/bridge.mjs';
2
+ import {
3
+ closeNativeSession,
4
+ createNativeSession,
5
+ nativeLabel,
6
+ nativeNode,
7
+ } from '../platform/native-session.mjs';
8
+
9
+ runAdapter(async (input) => {
10
+ const symbol = String(input.node?.symbol ?? '').trim().toUpperCase();
11
+ const timeoutMs = Number(input.node?.timeout_ms ?? 30_000);
12
+ if (!/^[A-Z0-9._-]{2,20}$/u.test(symbol)) {
13
+ throw new Error('metamask.assets.open_details requires a valid token symbol.');
14
+ }
15
+ const session = createNativeSession(input, 'Assets', `details-${process.pid}`);
16
+ try {
17
+ await session.open();
18
+ let snapshot = await session.snapshot();
19
+ if (!nativeNode(snapshot, 'token-asset-overview')) {
20
+ await session.execute('ui.press', {
21
+ test_id: `asset-${symbol}`,
22
+ timeout_ms: timeoutMs,
23
+ settle: false,
24
+ });
25
+ }
26
+ const deadline = Date.now() + timeoutMs;
27
+ while (Date.now() <= deadline) {
28
+ snapshot = await session.snapshot();
29
+ const symbolVisible = snapshot.nodes.some((node) => nativeLabel(node).toUpperCase() === symbol);
30
+ if (nativeNode(snapshot, 'token-asset-overview') && symbolVisible) {
31
+ return {
32
+ action: input.action,
33
+ symbol,
34
+ proofPath: 'native-accessibility-token-details-navigation',
35
+ };
36
+ }
37
+ await new Promise((resolve) => setTimeout(resolve, 200));
38
+ }
39
+ throw new Error(`Asset details for ${symbol} did not become visible.`);
40
+ } finally {
41
+ await closeNativeSession(session);
42
+ }
43
+ });