@deeeed/metamask-harness 0.38.1 → 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 (45) hide show
  1. package/CHANGELOG.md +12 -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/search_markets.mjs +32 -0
  6. package/library/actions/extension/perps/set_market_favorite.mjs +6 -1
  7. package/library/actions/extension/swap_bridge/read_visible_state.mjs +2 -6
  8. package/library/actions/extension/swap_bridge/set_amount.mjs +11 -2
  9. package/library/actions/mobile/assets/import_custom_token.mjs +181 -0
  10. package/library/actions/mobile/assets/open_details.mjs +43 -0
  11. package/library/actions/mobile/assets/read_details.mjs +96 -0
  12. package/library/actions/mobile/assets/set_token_visibility.mjs +302 -0
  13. package/library/actions/mobile/assets/verify_sorting.mjs +104 -0
  14. package/library/actions/mobile/networks/add_custom.mjs +125 -0
  15. package/library/actions/mobile/networks/network-management.mjs +132 -0
  16. package/library/actions/mobile/networks/read_visible_state.mjs +43 -0
  17. package/library/actions/mobile/networks/remove_custom.mjs +100 -0
  18. package/library/actions/mobile/perps/search_markets.mjs +46 -0
  19. package/library/actions/mobile/perps/set_market_favorite.mjs +117 -0
  20. package/library/actions/mobile/platform/native-session-name.mjs +14 -0
  21. package/library/actions/mobile/platform/native-session.mjs +59 -0
  22. package/library/actions/mobile/platform/observe-ui.mjs +9 -1
  23. package/library/actions/mobile/swap_bridge/native-session.mjs +10 -45
  24. package/library/actions/mobile/swap_bridge/select_assets.mjs +15 -1
  25. package/library/actions/mobile/swap_bridge/set_amount.mjs +1 -1
  26. package/library/actions/mobile/swap_bridge/set_max_amount.mjs +55 -0
  27. package/library/actions/mobile/swap_bridge/set_slippage.mjs +96 -0
  28. package/library/actions/mobile/swap_bridge/submit_transaction.mjs +141 -0
  29. package/library/actions/mobile/ui/native-navigation.mjs +75 -23
  30. package/library/actions/mobile/ui/navigate.mjs +13 -2
  31. package/library/actions/mobile/wallet/lock.mjs +14 -0
  32. package/library/actions/mobile/wallet/native-ui.mjs +67 -14
  33. package/library/actions/shared/perps/visible-state.mjs +8 -6
  34. package/library/actions/shared/swap-bridge/visible-state.mjs +15 -0
  35. package/library/manifests/extension.action-manifest.json +43 -2
  36. package/library/manifests/mobile.action-manifest.json +308 -1
  37. package/library/recipes/assets/release-token-details.recipe.json +5 -4
  38. package/library/recipes/assets/release-token-hide-readd.recipe.json +2 -0
  39. package/library/recipes/mobile/networks/release-custom-network-persistence.recipe.json +90 -0
  40. package/library/recipes/mobile/networks/release-multichain-visibility.recipe.json +58 -0
  41. package/library/recipes/perps/release-market-search.recipe.json +8 -4
  42. package/library/recipes/perps/release-watchlist.recipe.json +31 -19
  43. package/library/recipes/swap-bridge/release-custom-slippage.recipe.json +5 -13
  44. package/package.json +1 -1
  45. /package/library/recipes/{assets → extension/assets}/release-custom-network-token-persistence.recipe.json +0 -0
@@ -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
+ });
@@ -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 TEST_IDS = {
6
9
  existingWallet: 'wallet-setup-screen-have-an-existing-wallet-button-id',
@@ -85,6 +88,10 @@ function isWalletScreen(nodes) {
85
88
  return nodes.some((node) => WALLET_SCREEN_IDS.includes(String(node.identifier ?? '')));
86
89
  }
87
90
 
91
+ function visibleSnapshotNodes(nodes) {
92
+ return nodes.filter((node) => node.visibleToUser !== false);
93
+ }
94
+
88
95
  function hasBlockingSystemUi(nodes) {
89
96
  return nodes.some((node) =>
90
97
  (node.bundleId === 'com.android.systemui' || PERMISSION_CONTROLLER_PACKAGES.has(node.bundleId)) &&
@@ -101,7 +108,7 @@ export async function importWalletThroughNativeUi(input, credentials, options) {
101
108
  const native = nativeSession(input);
102
109
  try {
103
110
  const initial = await native.normalizeWalletSurface(options.timeoutMs);
104
- if (initial.wallet) {
111
+ if (initial.wallet && !initial.visible.has(TEST_IDS.walletTourSkip)) {
105
112
  if (options.requireFresh) {
106
113
  throw new Error('metamask.wallet.import method=ui requires a fresh Mobile onboarding state.');
107
114
  }
@@ -183,6 +190,27 @@ export async function unlockWalletThroughNativeUi(input, password, timeoutMs) {
183
190
  }
184
191
  }
185
192
 
193
+ export async function lockWalletThroughNativeUi(input) {
194
+ const native = nativeSession(input);
195
+ const timeoutMs = Number(input.node?.timeout_ms ?? 30_000);
196
+ try {
197
+ const state = await native.normalizeWalletSurface(timeoutMs);
198
+ if (state.visible.has(TEST_IDS.login)) {
199
+ return { alreadyLocked: true, provider: 'android-native-accessibility' };
200
+ }
201
+ if (!state.wallet) throw new Error('Opaque Mobile wallet lock could not reach wallet home.');
202
+ await native.press(TEST_IDS.hamburger, timeoutMs);
203
+ await native.press(TEST_IDS.lock, timeoutMs);
204
+ if (await native.exists(TEST_IDS.confirmLock, 3000)) {
205
+ await native.press(TEST_IDS.confirmLock, timeoutMs);
206
+ }
207
+ await native.wait(TEST_IDS.login, timeoutMs);
208
+ return { alreadyLocked: false, provider: 'android-native-accessibility' };
209
+ } finally {
210
+ await native.close();
211
+ }
212
+ }
213
+
186
214
  export async function resetWalletThroughNativeUi(input) {
187
215
  const native = nativeSession(input);
188
216
  const timeoutMs = Number(input.node?.timeout_ms ?? 60_000);
@@ -219,8 +247,10 @@ function nativeSession(input) {
219
247
  if (!device) throw new Error('Opaque Mobile UI execution requires one explicit Android device serial.');
220
248
  const app = String(env.ANDROID_PACKAGE_ID ?? 'io.metamask');
221
249
  const session = nativeSessionName(env, device, app);
250
+ const stateDir = nativeAgentDeviceStateDir(env, input.context.projectRoot, device);
222
251
  const client = createAgentDeviceClient({
223
252
  session,
253
+ stateDir,
224
254
  lockPolicy: 'reject',
225
255
  lockPlatform: 'android',
226
256
  });
@@ -230,6 +260,7 @@ function nativeSession(input) {
230
260
  device,
231
261
  app,
232
262
  session,
263
+ stateDir,
233
264
  client,
234
265
  });
235
266
  const context = {
@@ -240,7 +271,11 @@ function nativeSession(input) {
240
271
  };
241
272
  const execute = (action, node) => transport.execute(action, node, context);
242
273
  return {
243
- press: (testId, timeoutMs) => execute('ui.press', { test_id: testId, timeout_ms: timeoutMs }),
274
+ press: (testId, timeoutMs) => execute('ui.press', {
275
+ test_id: testId,
276
+ timeout_ms: timeoutMs,
277
+ settle: false,
278
+ }),
244
279
  fill: (testId, value, timeoutMs) => execute('ui.set_input', {
245
280
  test_id: testId,
246
281
  value,
@@ -259,7 +294,7 @@ function nativeSession(input) {
259
294
  interactiveOnly: false,
260
295
  forceFull: true,
261
296
  });
262
- const actual = new Map(snapshot.nodes
297
+ const actual = new Map(visibleSnapshotNodes(snapshot.nodes)
263
298
  .filter((node) => /^phrase-input-id_\d+$/u.test(String(node.identifier ?? '')))
264
299
  .map((node) => [
265
300
  Number(String(node.identifier).split('_').at(-1)),
@@ -288,11 +323,12 @@ function nativeSession(input) {
288
323
  interactiveOnly: false,
289
324
  forceFull: true,
290
325
  });
326
+ const nodes = visibleSnapshotNodes(snapshot.nodes);
291
327
  return {
292
- nodes: snapshot.nodes,
293
- visible: new Set(snapshot.nodes.map((node) => String(node.identifier ?? ''))),
294
- wallet: isWalletScreen(snapshot.nodes),
295
- postPassword: isPostPasswordScreen(snapshot.nodes) || isMetaMaskBiometricPrompt(snapshot.nodes),
328
+ nodes,
329
+ visible: new Set(nodes.map((node) => String(node.identifier ?? ''))),
330
+ wallet: isWalletScreen(nodes),
331
+ postPassword: isPostPasswordScreen(nodes) || isMetaMaskBiometricPrompt(nodes),
296
332
  };
297
333
  },
298
334
  async normalizeWalletSurface(timeoutMs) {
@@ -333,13 +369,15 @@ function nativeSession(input) {
333
369
  let walletTourSkipped = false;
334
370
  let interestsHandled = false;
335
371
  let awaitingNotificationPermission = false;
372
+ let stableWalletCaptures = 0;
336
373
  while (Date.now() <= deadline) {
337
374
  const snapshot = await client.capture.snapshot({
338
375
  ...selection,
339
376
  interactiveOnly: false,
340
377
  forceFull: true,
341
378
  });
342
- if (isMetaMaskBiometricPrompt(snapshot.nodes)) {
379
+ const nodes = visibleSnapshotNodes(snapshot.nodes);
380
+ if (isMetaMaskBiometricPrompt(nodes)) {
343
381
  if (options.biometricsEnabled) {
344
382
  throw new Error('Mobile biometrics=true requires manual device authentication and is not supported by an unattended recipe.');
345
383
  }
@@ -351,7 +389,7 @@ function nativeSession(input) {
351
389
  biometricPromptDismissed = true;
352
390
  continue;
353
391
  }
354
- if (isMetaMaskNotificationPermissionPrompt(snapshot.nodes) && awaitingNotificationPermission) {
392
+ if (isMetaMaskNotificationPermissionPrompt(nodes) && awaitingNotificationPermission) {
355
393
  await client.command.alert({
356
394
  ...selection,
357
395
  action: 'accept',
@@ -362,10 +400,12 @@ function nativeSession(input) {
362
400
  notificationsEnabled = true;
363
401
  continue;
364
402
  }
365
- if (hasBlockingSystemUi(snapshot.nodes)) {
403
+ if (hasBlockingSystemUi(nodes)) {
366
404
  throw new Error('Unexpected Android system UI blocked Mobile wallet creation; refusing to dismiss it as biometric authentication.');
367
405
  }
368
- const visible = new Set(snapshot.nodes.map((node) => String(node.identifier ?? '')));
406
+ const visible = new Set(nodes.map((node) => String(node.identifier ?? '')));
407
+ const walletVisible = WALLET_SCREEN_IDS.some((testId) => visible.has(testId));
408
+ if (!walletVisible) stableWalletCaptures = 0;
369
409
  if (visible.has(TEST_IDS.metricsContinue)) {
370
410
  if (!options.metametricsEnabled) {
371
411
  await this.press(TEST_IDS.metricsCheckbox, options.timeoutMs);
@@ -402,6 +442,7 @@ function nativeSession(input) {
402
442
  continue;
403
443
  }
404
444
  if (visible.has(TEST_IDS.walletTourSkip)) {
445
+ stableWalletCaptures = 0;
405
446
  if (options.skipWalletTour) {
406
447
  await this.press(TEST_IDS.walletTourSkip, options.timeoutMs);
407
448
  walletTourSkipped = true;
@@ -409,10 +450,15 @@ function nativeSession(input) {
409
450
  }
410
451
  throw new Error('Mobile wallet-home tour remains visible because skip_wallet_tour=false; wallet readiness cannot be proven.');
411
452
  }
412
- if (WALLET_SCREEN_IDS.some((testId) => visible.has(testId))) {
453
+ if (walletVisible) {
413
454
  if (options.interests.length > 0 && !interestsHandled) {
414
455
  throw new Error('Mobile onboarding interests were requested, but the questionnaire is not enabled for this build.');
415
456
  }
457
+ stableWalletCaptures += 1;
458
+ if (stableWalletCaptures < 2) {
459
+ await new Promise((resolve) => setTimeout(resolve, 500));
460
+ continue;
461
+ }
416
462
  return {
417
463
  biometricsEnabled: options.biometricsEnabled,
418
464
  biometricPromptDismissed,
@@ -433,8 +479,15 @@ function nativeSession(input) {
433
479
  }),
434
480
  async waitForWallet(timeoutMs) {
435
481
  const deadline = Date.now() + timeoutMs;
482
+ let stableCaptures = 0;
436
483
  while (Date.now() <= deadline) {
437
- if ((await this.screenState()).wallet) return;
484
+ const state = await this.screenState();
485
+ if (state.wallet && state.visible.has(TEST_IDS.walletAddress)) {
486
+ stableCaptures += 1;
487
+ if (stableCaptures >= 2) return;
488
+ } else {
489
+ stableCaptures = 0;
490
+ }
438
491
  await new Promise((resolve) => setTimeout(resolve, 250));
439
492
  }
440
493
  throw new Error('Mobile wallet home did not become visible before the readiness timeout.');
@@ -132,6 +132,7 @@ function marketFromTestId(testId) {
132
132
  for (const prefix of ['perps-watchlist-', 'explore-markets-', 'perps-market-row-item-', 'market-row-']) {
133
133
  if (!testId.startsWith(prefix)) continue;
134
134
  const raw = testId.slice(prefix.length);
135
+ if (/(?:-token-logo|-asset-label)$/u.test(raw)) return undefined;
135
136
  if ((prefix === 'explore-markets-' || prefix === 'market-row-') && raw.startsWith('xyz-')) {
136
137
  return `XYZ:${raw.slice(4).toUpperCase()}`;
137
138
  }
@@ -173,18 +174,19 @@ function compactEntries(items, maximum) {
173
174
  function visibleFeatures(items) {
174
175
  const ids = new Set(items.map((item) => item.testId));
175
176
  const has = (pattern) => [...ids].some((testId) => pattern.test(testId));
176
- const long = has(/(?:long-cta-button|direction-long)$/u);
177
- const short = has(/(?:short-cta-button|direction-short)$/u);
177
+ const long = has(/(?:long-cta-button|direction-long|market-details-long-button)$/u);
178
+ const short = has(/(?:short-cta-button|direction-short|market-details-short-button)$/u);
178
179
  return {
179
180
  activity: has(/^(?:transaction-card-|perps-(?:recent-activity|activity))/u),
180
181
  balance: has(/^perps-balance-dropdown/u),
181
- chart: has(/perps-(?:candlestick-chart|market-detail-chart|pro-market-chart)/u),
182
+ chart: has(/^(?:chart_surface|tv_chart_container)$/u) ||
183
+ has(/perps-(?:candlestick-chart|market-detail-chart|market-details-fullscreen-chart|pro-market-chart)/u),
182
184
  'long-short': long && short,
183
185
  'market-list': has(/^(?:market-list-view|perps-market-list|perps-watchlist-|explore-markets-|market-row-)/u),
184
186
  'order-entry': has(/perps-(?:order-entry|pro-order-form)/u),
185
- price: has(/perps-(?:market-detail-price|pro-market-price)$/u),
186
- 'price-change': has(/perps-(?:market-detail-change|pro-market-price-change)$/u),
187
- stats: has(/perps-(?:stats-section|market-detail-oracle-price|pro-market-stats)/u),
187
+ price: has(/perps-(?:market-detail-price|market-header-price|pro-market-price)$/u),
188
+ 'price-change': has(/perps-(?:market-detail-change|market-header-price-change|pro-market-price-change)$/u),
189
+ stats: has(/perps-(?:stats-section|market-details-market-summary|market-detail-oracle-price|pro-market-stats)/u),
188
190
  };
189
191
  }
190
192