@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
@@ -0,0 +1,55 @@
1
+ import { runAdapter } from '../platform/bridge.mjs';
2
+ import { resolveAmountOptions } from '../../shared/swap-bridge/transaction.mjs';
3
+ import {
4
+ closeNativeSession,
5
+ createNativeSession,
6
+ nativeLabel,
7
+ nativeNode,
8
+ } from '../platform/native-session.mjs';
9
+
10
+ runAdapter(async (input) => {
11
+ const options = resolveAmountOptions({
12
+ amount: '1',
13
+ wait_for_quote: input.node?.wait_for_quote,
14
+ timeout_ms: input.node?.timeout_ms,
15
+ });
16
+ const session = createNativeSession(input, 'Swap', `max-${process.pid}`);
17
+ try {
18
+ await session.open();
19
+ await session.execute('ui.press', {
20
+ test_id: 'token-input-area-max-button',
21
+ timeout_ms: options.timeoutMs,
22
+ settle: false,
23
+ });
24
+ const deadline = Date.now() + options.timeoutMs;
25
+ let observed;
26
+ do {
27
+ const snapshot = await session.snapshot();
28
+ const cta = nativeNode(snapshot, 'bridge-confirm-button')
29
+ ?? nativeNode(snapshot, 'bridge-confirm-button-keypad');
30
+ observed = {
31
+ sourceAmount: nativeLabel(nativeNode(snapshot, 'source-token-area-input')),
32
+ destinationAmount: nativeLabel(nativeNode(snapshot, 'dest-token-area-input')),
33
+ cta: nativeLabel(cta) || null,
34
+ ctaEnabled: cta?.enabled !== false,
35
+ noQuote: Boolean(nativeNode(snapshot, 'bridge-no-quotes')),
36
+ };
37
+ if (Number(observed.sourceAmount) > 0 && (
38
+ !options.waitForQuote
39
+ || observed.noQuote
40
+ || (Number(observed.destinationAmount) > 0 && cta)
41
+ )) break;
42
+ await new Promise((resolve) => setTimeout(resolve, 250));
43
+ } while (Date.now() < deadline);
44
+ if (!(Number(observed?.sourceAmount) > 0)) {
45
+ throw new Error('Swap Max did not populate a positive source amount.');
46
+ }
47
+ if (options.waitForQuote && !observed.noQuote
48
+ && (!(Number(observed.destinationAmount) > 0) || !observed.cta)) {
49
+ throw new Error(`Swap Max quote did not resolve within ${options.timeoutMs}ms.`);
50
+ }
51
+ return { action: input.action, ...observed, proofPath: 'native-accessibility-max-quote' };
52
+ } finally {
53
+ await closeNativeSession(session);
54
+ }
55
+ });
@@ -0,0 +1,96 @@
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 waitForNode(session, testId, timeoutMs) {
10
+ const deadline = Date.now() + timeoutMs;
11
+ let snapshot;
12
+ while (Date.now() <= deadline) {
13
+ snapshot = await session.snapshot();
14
+ const node = nativeNode(snapshot, testId);
15
+ if (node) return { node, snapshot };
16
+ await new Promise((resolve) => setTimeout(resolve, 200));
17
+ }
18
+ throw new Error(`Swap control ${testId} did not become visible.`);
19
+ }
20
+
21
+ async function replaceSlippage(session, percent, timeoutMs) {
22
+ const inputId = 'input-stepper-input';
23
+ await waitForNode(session, inputId, timeoutMs);
24
+ await session.execute('ui.press', { test_id: inputId, timeout_ms: timeoutMs, settle: false });
25
+ let current = nativeLabel(nativeNode(await session.snapshot(), inputId));
26
+ for (let index = 0; index < Math.min(20, current.length + 2); index += 1) {
27
+ await session.execute('ui.press', {
28
+ test_id: 'keypad-delete-button',
29
+ timeout_ms: timeoutMs,
30
+ settle: false,
31
+ });
32
+ }
33
+ current = nativeLabel(nativeNode(await session.snapshot(), inputId));
34
+ const value = String(percent);
35
+ const keys = value.startsWith('0.') && current === '0' ? value.slice(1) : value;
36
+ for (const key of keys) {
37
+ if (!/[0-9.]/u.test(key)) {
38
+ throw new Error(`Swap slippage contains unsupported keypad character ${JSON.stringify(key)}.`);
39
+ }
40
+ await session.execute('ui.press', {
41
+ test_id: key === '.' ? 'keypad-key-dot' : `keypad-key-${key}`,
42
+ timeout_ms: timeoutMs,
43
+ settle: false,
44
+ });
45
+ }
46
+ const observed = nativeLabel(nativeNode(await session.snapshot(), inputId));
47
+ if (Number(observed) !== percent) {
48
+ throw new Error(`Swap slippage input mismatch: expected ${percent}, observed ${JSON.stringify(observed)}.`);
49
+ }
50
+ }
51
+
52
+ runAdapter(async (input) => {
53
+ const percent = Number(input.node?.percent);
54
+ const timeoutMs = Number(input.node?.timeout_ms ?? 30_000);
55
+ if (!Number.isFinite(percent) || percent <= 0 || percent > 50) {
56
+ throw new Error('metamask.swap_bridge.set_slippage requires percent greater than 0 and at most 50.');
57
+ }
58
+ const session = createNativeSession(input, 'Swap', `slippage-${process.pid}`);
59
+ try {
60
+ await session.open();
61
+ const initial = await session.snapshot();
62
+ const opener = nativeNode(initial, 'edit-slippage-button')
63
+ ? 'edit-slippage-button'
64
+ : 'bridge-slippage-settings-button';
65
+ await session.execute('ui.press', { test_id: opener, timeout_ms: timeoutMs, settle: false });
66
+ await session.execute('ui.press', { text: 'Custom', timeout_ms: timeoutMs, settle: false });
67
+ await replaceSlippage(session, percent, timeoutMs);
68
+ await session.execute('ui.press', { text: 'Confirm', timeout_ms: timeoutMs, settle: false });
69
+
70
+ const deadline = Date.now() + timeoutMs;
71
+ let observed = null;
72
+ let destinationAmount = null;
73
+ while (Date.now() <= deadline) {
74
+ const snapshot = await session.snapshot();
75
+ observed = nativeLabel(nativeNode(snapshot, 'edit-slippage-button')) || null;
76
+ destinationAmount = nativeLabel(nativeNode(snapshot, 'dest-token-area-input')) || null;
77
+ if (Number.parseFloat(observed) === percent && Number(destinationAmount) > 0) break;
78
+ await new Promise((resolve) => setTimeout(resolve, 250));
79
+ }
80
+ if (Number.parseFloat(observed) !== percent) {
81
+ throw new Error(`Swap quote did not display Slippage: ${percent}%; observed ${JSON.stringify(observed)}.`);
82
+ }
83
+ if (!(Number(destinationAmount) > 0)) {
84
+ throw new Error('Swap quote did not finish refreshing after custom slippage was applied.');
85
+ }
86
+ return {
87
+ action: input.action,
88
+ percent,
89
+ observed,
90
+ destinationAmount,
91
+ proofPath: 'native-accessibility-custom-slippage',
92
+ };
93
+ } finally {
94
+ await closeNativeSession(session);
95
+ }
96
+ });
@@ -0,0 +1,141 @@
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
+ function visibleLabels(snapshot) {
10
+ return snapshot.nodes
11
+ .filter((node) => node.visibleToUser !== false)
12
+ .map(nativeLabel)
13
+ .filter(Boolean);
14
+ }
15
+
16
+ function quote(snapshot) {
17
+ const source = nativeNode(snapshot, 'source-token-area');
18
+ const destination = nativeNode(snapshot, 'dest-token-area');
19
+ const sourceAmount = nativeLabel(nativeNode(snapshot, 'source-token-area-input'));
20
+ const destinationAmount = nativeLabel(nativeNode(snapshot, 'dest-token-area-input'));
21
+ const button = nativeNode(snapshot, 'bridge-confirm-button');
22
+ const labels = visibleLabels(snapshot);
23
+ return {
24
+ source: nativeLabel(source),
25
+ destination: nativeLabel(destination),
26
+ sourceAmount,
27
+ destinationAmount,
28
+ buttonEnabled: button?.enabled === true,
29
+ warning: labels.find((label) => /insufficient|no quotes|unavailable|may fail|might fail/iu.test(label)) ?? null,
30
+ };
31
+ }
32
+
33
+ async function waitForQuote(session, expected, timeoutMs) {
34
+ const deadline = Date.now() + Math.min(timeoutMs, 60_000);
35
+ let stable = null;
36
+ let stableSince = 0;
37
+ while (Date.now() <= deadline) {
38
+ const snapshot = await session.snapshot();
39
+ const current = quote(snapshot);
40
+ const matches = current.source.toUpperCase().includes(expected.sourceSymbol) &&
41
+ current.destination.toUpperCase().includes(expected.destinationSymbol) &&
42
+ Number(current.sourceAmount) === expected.amount &&
43
+ Number(current.destinationAmount) > 0 &&
44
+ current.buttonEnabled &&
45
+ !current.warning;
46
+ const identity = JSON.stringify(current);
47
+ if (!matches) {
48
+ stable = null;
49
+ stableSince = 0;
50
+ } else if (stable !== identity) {
51
+ stable = identity;
52
+ stableSince = Date.now();
53
+ } else if (Date.now() - stableSince >= 1500) {
54
+ return current;
55
+ }
56
+ await new Promise((resolve) => setTimeout(resolve, 250));
57
+ }
58
+ throw new Error('The Mobile Swap quote did not remain submittable for the requested pair and amount.');
59
+ }
60
+
61
+ async function pressIfVisible(session, testId, timeoutMs) {
62
+ if (!nativeNode(await session.snapshot(), testId)) return false;
63
+ await session.execute('ui.press', { test_id: testId, timeout_ms: timeoutMs, settle: false });
64
+ return true;
65
+ }
66
+
67
+ async function waitForTerminalStatus(session, timeoutMs) {
68
+ const deadline = Date.now() + timeoutMs;
69
+ while (Date.now() <= deadline) {
70
+ const snapshot = await session.snapshot();
71
+ const labels = visibleLabels(snapshot);
72
+ const failed = labels.find((label) => /^(?:Swap|Bridge) failed$/iu.test(label));
73
+ if (failed) throw new Error(`Mobile ${failed}.`);
74
+ const success = labels.find((label) => /^(?:Swap|Bridge) complete$/iu.test(label));
75
+ if (success) return { snapshot, status: success };
76
+ if (nativeNode(snapshot, 'price-impact-footer-confirm')) {
77
+ await session.execute('ui.press', {
78
+ test_id: 'price-impact-footer-confirm',
79
+ timeout_ms: Math.max(1, deadline - Date.now()),
80
+ settle: false,
81
+ });
82
+ }
83
+ await new Promise((resolve) => setTimeout(resolve, 500));
84
+ }
85
+ throw new Error('Mobile Swap did not reach a terminal post-trade status.');
86
+ }
87
+
88
+ runAdapter(async (input) => {
89
+ const sourceSymbol = String(input.node?.source_symbol ?? '').trim().toUpperCase();
90
+ const destinationSymbol = String(input.node?.destination_symbol ?? '').trim().toUpperCase();
91
+ const amount = Number(input.node?.amount);
92
+ const timeoutMs = Number(input.node?.timeout_ms ?? 300_000);
93
+ if (!sourceSymbol || !destinationSymbol || !(amount > 0)) {
94
+ throw new Error('metamask.swap_bridge.submit_transaction requires source_symbol, destination_symbol, and positive amount.');
95
+ }
96
+ if (input.node?.confirm_live !== true) {
97
+ throw new Error('metamask.swap_bridge.submit_transaction requires confirm_live=true.');
98
+ }
99
+
100
+ const session = createNativeSession(input, 'Swap', `submit-${process.pid}`);
101
+ try {
102
+ await session.open();
103
+ const before = await waitForQuote(session, { sourceSymbol, destinationSymbol, amount }, timeoutMs);
104
+ await session.execute('ui.press', {
105
+ test_id: 'bridge-confirm-button',
106
+ timeout_ms: Math.min(timeoutMs, 60_000),
107
+ settle: false,
108
+ });
109
+ const terminal = await waitForTerminalStatus(session, timeoutMs);
110
+ await pressIfVisible(session, 'post-trade-bottom-sheet-view-activity-button', timeoutMs);
111
+ let activity = null;
112
+ const activityDeadline = Date.now() + Math.min(timeoutMs, 60_000);
113
+ while (Date.now() <= activityDeadline) {
114
+ const snapshot = await session.snapshot();
115
+ if (nativeNode(snapshot, 'activity-screen-safe-area-view')) {
116
+ const labels = visibleLabels(snapshot);
117
+ activity = labels.find((label) => {
118
+ const upper = label.toUpperCase();
119
+ return upper.includes(sourceSymbol) && upper.includes(destinationSymbol);
120
+ }) ?? labels.find((label) => /swap|bridge/iu.test(label)) ?? null;
121
+ if (activity) break;
122
+ }
123
+ await new Promise((resolve) => setTimeout(resolve, 300));
124
+ }
125
+ if (!activity) throw new Error('Confirmed Mobile Swap did not appear in visible Activity.');
126
+ return {
127
+ action: input.action,
128
+ sourceSymbol,
129
+ destinationSymbol,
130
+ amount,
131
+ destinationAmount: before.destinationAmount,
132
+ submitted: true,
133
+ confirmed: true,
134
+ status: terminal.status,
135
+ activity,
136
+ proofPath: 'native-accessibility-swap-post-trade-activity',
137
+ };
138
+ } finally {
139
+ await closeNativeSession(session);
140
+ }
141
+ });
@@ -1,6 +1,9 @@
1
1
  import { createAgentDeviceUiTransport } from '@farmslot/expo-recipe';
2
2
  import { createAgentDeviceClient } from 'agent-device';
3
- import { nativeSessionName } from '../platform/native-session-name.mjs';
3
+ import {
4
+ nativeAgentDeviceStateDir,
5
+ nativeSessionName,
6
+ } from '../platform/native-session-name.mjs';
4
7
 
5
8
  const IDS = {
6
9
  wallet: ['wallet-safe-area', 'homepage-container'],
@@ -9,12 +12,16 @@ const IDS = {
9
12
  perpsEntry: 'homepage-balance-breakdown-row-perps',
10
13
  perpsTradeEntry: 'wallet-perps-action',
11
14
  swapTradeEntry: 'wallet-actions-bottom-sheet-swap-button',
15
+ swapBack: 'button-icon',
12
16
  swapSourceInput: 'source-token-area-input',
13
17
  swapSourceToken: 'source-token-area',
14
18
  swapDestinationToken: 'dest-token-area',
15
19
  perpsHome: 'perps-home',
16
20
  perpsHomeBack: 'perps-home-back-button',
21
+ perpsSearch: 'perps-home-search-toggle',
17
22
  tutorialSkip: 'perps-tutorial-skip-button',
23
+ modeLite: 'perps-mode-selection-lite-option',
24
+ modePro: 'perps-mode-selection-pro-option',
18
25
  market: 'perps-market-details-view',
19
26
  marketBack: 'perps-market-header-back-button',
20
27
  marketSubtitle: 'perps-market-header-subtitle',
@@ -23,6 +30,11 @@ const IDS = {
23
30
  proMarketList: 'perps-pro-market-header-market-list-button',
24
31
  proMarketSubtitle: 'perps-pro-market-header-subtitle',
25
32
  marketList: 'perps-market-list',
33
+ marketListClose: 'perps-market-list-close-button-back-button',
34
+ tokenList: 'token-list',
35
+ tokenListBack: 'back-button',
36
+ tokenDetails: 'token-asset-overview',
37
+ tokenDetailsBack: 'back-arrow-button',
26
38
  };
27
39
 
28
40
  export async function navigateOpaqueMobile(input, destination) {
@@ -33,8 +45,10 @@ export async function navigateOpaqueMobile(input, destination) {
33
45
  }
34
46
  const app = String(env.ANDROID_PACKAGE_ID ?? 'io.metamask');
35
47
  const session = nativeSessionName(env, device, app);
48
+ const stateDir = nativeAgentDeviceStateDir(env, input.context.projectRoot, device);
36
49
  const client = createAgentDeviceClient({
37
50
  session,
51
+ stateDir,
38
52
  lockPolicy: 'reject',
39
53
  lockPlatform: 'android',
40
54
  });
@@ -44,6 +58,7 @@ export async function navigateOpaqueMobile(input, destination) {
44
58
  device,
45
59
  app,
46
60
  session,
61
+ stateDir,
47
62
  client,
48
63
  });
49
64
  const context = {
@@ -52,9 +67,11 @@ export async function navigateOpaqueMobile(input, destination) {
52
67
  artifactsDir: input.context.artifactsDir,
53
68
  env,
54
69
  };
70
+ const perpsMode = destination.params?.mode === 'pro' ? 'pro' : 'lite';
55
71
  const press = (testId) => transport.execute('ui.press', {
56
72
  test_id: testId,
57
73
  timeout_ms: Number(input.node?.timeout_ms ?? 30_000),
74
+ settle: false,
58
75
  }, context);
59
76
  const wait = (testId) => transport.execute('ui.wait_for', {
60
77
  test_id: testId,
@@ -80,7 +97,9 @@ export async function navigateOpaqueMobile(input, destination) {
80
97
  forceFull: true,
81
98
  });
82
99
  const identifiers = async () => new Set(
83
- (await snapshot()).nodes.map((node) => String(node.identifier ?? '')),
100
+ (await snapshot()).nodes
101
+ .filter((node) => node.visibleToUser !== false)
102
+ .map((node) => String(node.identifier ?? '')),
84
103
  );
85
104
 
86
105
  try {
@@ -91,25 +110,25 @@ export async function navigateOpaqueMobile(input, destination) {
91
110
  } else if (destination.page === 'swap') {
92
111
  observedScreen = await navigateSwap({ identifiers, press, wait, back });
93
112
  } else if (destination.page === 'perps-market-list') {
94
- observedScreen = await navigatePerpsMarketList({ identifiers, press, wait, back, timeoutMs: Number(input.node?.timeout_ms ?? 30_000) });
113
+ observedScreen = await navigatePerpsMarketList({ identifiers, press, wait, back, timeoutMs: Number(input.node?.timeout_ms ?? 30_000), perpsMode });
95
114
  } else {
96
115
  const timeoutMs = Number(input.node?.timeout_ms ?? 30_000);
97
116
  const initialIds = await identifiers();
98
117
  observedScreen = destination.page === 'perps-market' && initialIds.has(IDS.marketList)
99
- ? await selectMarket({ destination, identifiers, press, snapshot, timeoutMs })
100
- : await navigatePerps({ identifiers, press, wait, back, timeoutMs });
118
+ ? await selectMarket({ destination, identifiers, press, snapshot, timeoutMs, perpsMode })
119
+ : await navigatePerps({ identifiers, press, wait, back, timeoutMs, perpsMode });
101
120
  if (destination.page === 'perps-market') {
102
121
  const market = String(destination.params.market.symbol).toUpperCase();
103
122
  const ids = await identifiers();
104
123
  if (ids.has(IDS.proMarket) && !await marketMatches(snapshot, market)) {
105
124
  await press(IDS.proMarketList);
106
- observedScreen = await selectMarket({ destination, identifiers, press, snapshot, timeoutMs });
125
+ observedScreen = await selectMarket({ destination, identifiers, press, snapshot, timeoutMs, perpsMode });
107
126
  } else if (!ids.has(IDS.proMarket) && (!ids.has(IDS.market) || !await marketMatches(snapshot, market))) {
108
127
  if (ids.has(IDS.market)) await press(IDS.marketBack);
109
128
  await press(`perps-market-row-item-${market}`);
110
129
  observedScreen = IDS.market;
111
130
  }
112
- await waitForPerpsDestination(identifiers, timeoutMs);
131
+ await waitForPerpsDestination(identifiers, timeoutMs, press, perpsMode);
113
132
  if (!await marketMatches(snapshot, market)) {
114
133
  throw new Error(`Opaque Mobile navigation opened a different Perps market than ${market}.`);
115
134
  }
@@ -147,10 +166,10 @@ async function navigateSwap({ identifiers, press, wait, back }) {
147
166
  return IDS.swapSourceInput;
148
167
  }
149
168
 
150
- async function selectMarket({ destination, identifiers, press, snapshot, timeoutMs }) {
169
+ async function selectMarket({ destination, identifiers, press, snapshot, timeoutMs, perpsMode }) {
151
170
  const market = String(destination.params.market.symbol).toUpperCase();
152
171
  await press(`perps-market-row-item-${market}`);
153
- const observed = await waitForPerpsDestination(identifiers, timeoutMs);
172
+ const observed = await waitForPerpsDestination(identifiers, timeoutMs, press, perpsMode);
154
173
  if (!await marketMatches(snapshot, market)) {
155
174
  throw new Error(`Opaque Mobile navigation opened a different Perps market than ${market}.`);
156
175
  }
@@ -162,32 +181,56 @@ async function navigateHome({ identifiers, press, wait, back }) {
162
181
  const ids = await identifiers();
163
182
  if (IDS.wallet.some((id) => ids.has(id))) return;
164
183
  if (ids.has(IDS.walletTab)) {
165
- await press(IDS.walletTab);
166
- await wait(IDS.wallet[0]);
167
- return;
184
+ if (await pressCurrent(press, IDS.walletTab)) {
185
+ await wait(IDS.wallet[0]);
186
+ return;
187
+ }
188
+ continue;
168
189
  }
169
190
  if (ids.has(IDS.marketBack)) {
170
- await press(IDS.marketBack);
191
+ await pressCurrent(press, IDS.marketBack);
171
192
  continue;
172
193
  }
173
194
  if (ids.has(IDS.proMarketBack)) {
174
- await press(IDS.proMarketBack);
195
+ await pressCurrent(press, IDS.proMarketBack);
175
196
  continue;
176
197
  }
177
198
  if (ids.has(IDS.perpsHomeBack)) {
178
- await press(IDS.perpsHomeBack);
199
+ await pressCurrent(press, IDS.perpsHomeBack);
179
200
  continue;
180
201
  }
181
- if (ids.has(IDS.perpsTradeEntry)) {
182
- await back();
202
+ if (ids.has(IDS.marketListClose)) {
203
+ await pressCurrent(press, IDS.marketListClose);
204
+ continue;
205
+ }
206
+ if (ids.has(IDS.tokenList) && ids.has(IDS.tokenListBack)) {
207
+ await pressCurrent(press, IDS.tokenListBack);
208
+ continue;
209
+ }
210
+ if (ids.has(IDS.tokenDetails) && ids.has(IDS.tokenDetailsBack)) {
211
+ await pressCurrent(press, IDS.tokenDetailsBack);
212
+ continue;
213
+ }
214
+ if (ids.has(IDS.swapSourceInput) && ids.has(IDS.swapBack)) {
215
+ await pressCurrent(press, IDS.swapBack);
183
216
  continue;
184
217
  }
185
- throw new Error('Opaque Mobile navigation could not find a visible route back to wallet home.');
218
+ await back();
186
219
  }
187
220
  throw new Error('Opaque Mobile navigation did not reach wallet home.');
188
221
  }
189
222
 
190
- async function navigatePerps({ identifiers, press, wait, back, timeoutMs }) {
223
+ async function pressCurrent(press, testId) {
224
+ try {
225
+ await press(testId);
226
+ return true;
227
+ } catch (error) {
228
+ if (!/Selector did not match/iu.test(String(error?.message ?? error))) throw error;
229
+ return false;
230
+ }
231
+ }
232
+
233
+ async function navigatePerps({ identifiers, press, wait, back, timeoutMs, perpsMode }) {
191
234
  let ids = await identifiers();
192
235
  if (ids.has(IDS.proMarket)) return IDS.proMarket;
193
236
  if (ids.has(IDS.market)) {
@@ -219,15 +262,20 @@ async function navigatePerps({ identifiers, press, wait, back, timeoutMs }) {
219
262
  ids = await identifiers();
220
263
  }
221
264
  if (ids.has(IDS.tutorialSkip)) await press(IDS.tutorialSkip);
222
- return waitForPerpsDestination(identifiers, timeoutMs);
265
+ return waitForPerpsDestination(identifiers, timeoutMs, press, perpsMode);
223
266
  }
224
267
 
225
- async function navigatePerpsMarketList({ identifiers, press, wait, back, timeoutMs }) {
268
+ async function navigatePerpsMarketList({ identifiers, press, wait, back, timeoutMs, perpsMode }) {
226
269
  let ids = await identifiers();
227
270
  if (ids.has(IDS.marketList)) return IDS.marketList;
228
- const observed = await navigatePerps({ identifiers, press, wait, back, timeoutMs });
271
+ const observed = await navigatePerps({ identifiers, press, wait, back, timeoutMs, perpsMode });
229
272
  ids = await identifiers();
230
273
  if (ids.has(IDS.marketList)) return IDS.marketList;
274
+ if (ids.has(IDS.perpsSearch)) {
275
+ await press(IDS.perpsSearch);
276
+ await wait(IDS.marketList);
277
+ return IDS.marketList;
278
+ }
231
279
  if (ids.has(IDS.proMarketList)) {
232
280
  await press(IDS.proMarketList);
233
281
  await wait(IDS.marketList);
@@ -236,10 +284,14 @@ async function navigatePerpsMarketList({ identifiers, press, wait, back, timeout
236
284
  throw new Error(`Opaque Mobile navigation reached ${observed}, but no visible market-list control was available.`);
237
285
  }
238
286
 
239
- async function waitForPerpsDestination(identifiers, timeoutMs) {
287
+ async function waitForPerpsDestination(identifiers, timeoutMs, press, perpsMode = 'lite') {
240
288
  const deadline = Date.now() + timeoutMs;
241
289
  while (Date.now() <= deadline) {
242
290
  const ids = await identifiers();
291
+ if (ids.has(IDS.modeLite) || ids.has(IDS.modePro)) {
292
+ await press(perpsMode === 'pro' ? IDS.modePro : IDS.modeLite);
293
+ continue;
294
+ }
243
295
  for (const id of [IDS.perpsHome, IDS.market, IDS.proMarket]) {
244
296
  if (ids.has(id)) return id;
245
297
  }
@@ -15,11 +15,22 @@ function text(value) {
15
15
  function pageRoute(node) {
16
16
  const page = text(node?.page);
17
17
  if (!page) return undefined;
18
- if (PAGE_ROUTES[page]) return { page, ...PAGE_ROUTES[page] };
18
+ const mode = text(node?.mode)?.toLowerCase() ?? 'lite';
19
+ if (!['lite', 'pro'].includes(mode)) {
20
+ throw new Error('mobile ui.navigate mode must be lite or pro.');
21
+ }
22
+ if (PAGE_ROUTES[page]) {
23
+ const route = PAGE_ROUTES[page];
24
+ return {
25
+ page,
26
+ ...route,
27
+ params: page.startsWith('perps') ? { ...route.params, mode } : route.params,
28
+ };
29
+ }
19
30
  if (page === 'perps-market') {
20
31
  const market = text(node.market) ?? text(node.symbol) ?? text(node.params?.market?.symbol);
21
32
  if (!market) throw new Error('mobile ui.navigate page=perps-market requires market or symbol.');
22
- return { page, route: 'PerpsMarketDetails', params: { market: { symbol: market } } };
33
+ return { page, route: 'PerpsMarketDetails', params: { market: { symbol: market }, mode } };
23
34
  }
24
35
  throw new Error('mobile ui.navigate supported page intents: home, perps, perps-market-list, perps-market, swap.');
25
36
  }
@@ -0,0 +1,14 @@
1
+ import { runAdapter } from '../platform/bridge.mjs';
2
+ import { isOpaqueMobileRuntime, lockWalletThroughNativeUi } from './native-ui.mjs';
3
+
4
+ runAdapter(async (input) => {
5
+ if (!isOpaqueMobileRuntime(input)) {
6
+ throw new Error('metamask.wallet.lock requires an opaque Mobile runtime.');
7
+ }
8
+ return {
9
+ action: input.action,
10
+ ...await lockWalletThroughNativeUi(input),
11
+ locked: true,
12
+ proofPath: 'mobile-visible-wallet-lock-native',
13
+ };
14
+ });