@deeeed/metamask-harness 0.36.0 → 0.38.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (140) hide show
  1. package/CHANGELOG.md +51 -0
  2. package/adapters/extension/artifact-runtime-state.cjs +138 -0
  3. package/adapters/extension/inject.mjs +2 -0
  4. package/adapters/extension/launch-browser.cjs +24 -0
  5. package/adapters/extension/live.sh +82 -12
  6. package/adapters/extension/readiness.mjs +77 -36
  7. package/adapters/extension/snapshot-dist.sh +88 -3
  8. package/adapters/extension/verify.sh +6 -2
  9. package/adapters/manifest.json +17 -1
  10. package/adapters/shared/log-tui.mjs +1 -1
  11. package/dist/adapters/extension/artifact-integrity.js +38 -0
  12. package/dist/adapters/extension/extension-id.js +23 -4
  13. package/dist/adapters/extension/release-artifact.js +386 -0
  14. package/dist/adapters/extension/runtime-decision.js +164 -20
  15. package/dist/adapters/extension/runtime.js +169 -3
  16. package/dist/adapters/mobile/provision.js +38 -3
  17. package/dist/adapters/mobile/release-artifact-state.js +124 -0
  18. package/dist/adapters/mobile/release-artifact.js +295 -0
  19. package/dist/adapters.js +78 -6
  20. package/dist/app-lifecycle.js +4 -3
  21. package/dist/command-contract.js +16 -0
  22. package/dist/commands/call.js +6 -2
  23. package/dist/commands/launch/extension.js +74 -9
  24. package/dist/commands/launch/index.js +3 -0
  25. package/dist/commands/launch/mobile.js +2 -0
  26. package/dist/commands/provision.js +2 -0
  27. package/dist/commands/run-engine.js +109 -14
  28. package/dist/commands/run.js +4 -2
  29. package/dist/commands/runtime-launch.js +178 -10
  30. package/dist/heal-bounds.js +1 -1
  31. package/dist/live-adapter-contract.js +3 -1
  32. package/dist/metamask-action-validation.js +47 -1
  33. package/dist/mm-harness-cli.js +31 -1
  34. package/dist/recipe-security.js +40 -0
  35. package/dist/run-diagnostics.js +1 -1
  36. package/dist/runner.js +3 -0
  37. package/docs/RELEASE-QA-CAPABILITY-MAP.md +125 -0
  38. package/library/actions/extension/assets/finish_send.mjs +128 -0
  39. package/library/actions/extension/assets/import_custom_token.mjs +227 -0
  40. package/library/actions/extension/assets/prepare_send.mjs +53 -0
  41. package/library/actions/extension/assets/read_details.mjs +57 -0
  42. package/library/actions/extension/assets/read_visible_state.mjs +50 -0
  43. package/library/actions/extension/assets/review_send.mjs +23 -0
  44. package/library/actions/extension/assets/select_network_scope.mjs +30 -0
  45. package/library/actions/extension/assets/send.mjs +104 -0
  46. package/library/actions/extension/assets/set_custom_gas.mjs +56 -0
  47. package/library/actions/extension/assets/set_token_visibility.mjs +129 -0
  48. package/library/actions/extension/assets/verify_sorting.mjs +89 -0
  49. package/library/actions/extension/networks/add_chainlist.mjs +94 -0
  50. package/library/actions/extension/networks/add_custom.mjs +123 -0
  51. package/library/actions/extension/networks/custom_network.mjs +103 -0
  52. package/library/actions/extension/networks/read_visible_state.mjs +50 -0
  53. package/library/actions/extension/networks/remove_custom.mjs +68 -0
  54. package/library/actions/extension/perps/close_orders.mjs +1 -0
  55. package/library/actions/extension/perps/close_visible_position.mjs +133 -0
  56. package/library/actions/extension/perps/compare_provider_market.mjs +109 -0
  57. package/library/actions/extension/perps/edit_margin.mjs +82 -0
  58. package/library/actions/extension/perps/open_balance_action.mjs +58 -0
  59. package/library/actions/extension/perps/open_position_action.mjs +80 -0
  60. package/library/actions/extension/perps/perps.mjs +196 -2
  61. package/library/actions/extension/perps/place_order.mjs +5 -2
  62. package/library/actions/extension/perps/read_eligibility.mjs +34 -0
  63. package/library/actions/extension/perps/read_funds_confirmation.mjs +69 -0
  64. package/library/actions/extension/perps/read_snapshot.mjs +470 -0
  65. package/library/actions/extension/perps/read_visible_state.mjs +98 -0
  66. package/library/actions/extension/perps/select_activity_filter.mjs +44 -0
  67. package/library/actions/extension/perps/select_market_filter.mjs +71 -0
  68. package/library/actions/extension/perps/set_market_favorite.mjs +47 -0
  69. package/library/actions/extension/perps/update_position_tpsl.mjs +106 -0
  70. package/library/actions/extension/platform/cdp.mjs +109 -3
  71. package/library/actions/extension/settings/set_basic_functionality.mjs +58 -0
  72. package/library/actions/extension/swap_bridge/read_visible_state.mjs +71 -0
  73. package/library/actions/extension/swap_bridge/select_assets.mjs +103 -0
  74. package/library/actions/extension/swap_bridge/set_amount.mjs +36 -0
  75. package/library/actions/extension/swap_bridge/set_max_amount.mjs +58 -0
  76. package/library/actions/extension/swap_bridge/set_slippage.mjs +121 -0
  77. package/library/actions/extension/swap_bridge/submit_transaction.mjs +363 -0
  78. package/library/actions/extension/ui/activate_and_observe.mjs +96 -0
  79. package/library/actions/extension/ui/navigate.mjs +264 -8
  80. package/library/actions/extension/wallet/ensure_unlocked.mjs +2 -6
  81. package/library/actions/extension/wallet/import.mjs +13 -46
  82. package/library/actions/extension/wallet/lock.mjs +35 -0
  83. package/library/actions/extension/wallet/secret-input.mjs +98 -0
  84. package/library/actions/extension/wallet/select_account.mjs +71 -7
  85. package/library/actions/mobile/assets/read_visible_state.mjs +37 -0
  86. package/library/actions/mobile/perps/read_visible_state.mjs +31 -0
  87. package/library/actions/mobile/platform/native-session-name.mjs +5 -0
  88. package/library/actions/mobile/platform/observe-ui.mjs +86 -2
  89. package/library/actions/mobile/swap_bridge/native-session.mjs +45 -0
  90. package/library/actions/mobile/swap_bridge/read_visible_state.mjs +31 -0
  91. package/library/actions/mobile/swap_bridge/select_assets.mjs +94 -0
  92. package/library/actions/mobile/swap_bridge/set_amount.mjs +73 -0
  93. package/library/actions/mobile/ui/native-navigation.mjs +265 -0
  94. package/library/actions/mobile/ui/navigate.mjs +10 -1
  95. package/library/actions/mobile/wallet/ensure_unlocked.mjs +17 -0
  96. package/library/actions/mobile/wallet/import.mjs +71 -2
  97. package/library/actions/mobile/wallet/native-ui.mjs +512 -0
  98. package/library/actions/mobile/wallet/read_state.mjs +16 -0
  99. package/library/actions/mobile/wallet/reset.mjs +17 -4
  100. package/library/actions/shared/assets/visible-state.mjs +176 -0
  101. package/library/actions/shared/networks/visible-state.mjs +70 -0
  102. package/library/actions/shared/perps/visible-state.mjs +354 -0
  103. package/library/actions/shared/swap-bridge/transaction.mjs +54 -0
  104. package/library/actions/shared/swap-bridge/visible-state.mjs +97 -0
  105. package/library/actions/shared/ui/locators.mjs +7 -0
  106. package/library/manifests/extension.action-manifest.json +1366 -184
  107. package/library/manifests/mobile.action-manifest.json +235 -1
  108. package/library/recipes/assets/release-custom-gas-send.recipe.json +88 -0
  109. package/library/recipes/assets/release-custom-network-token-persistence.recipe.json +106 -0
  110. package/library/recipes/assets/release-native-max-cancel.recipe.json +70 -0
  111. package/library/recipes/assets/release-native-max-send.recipe.json +78 -0
  112. package/library/recipes/assets/release-own-account-send.recipe.json +80 -0
  113. package/library/recipes/assets/release-token-address-import.recipe.json +47 -0
  114. package/library/recipes/assets/release-token-details.recipe.json +48 -0
  115. package/library/recipes/assets/release-token-hide-readd.recipe.json +55 -0
  116. package/library/recipes/assets/release-token-list.recipe.json +75 -0
  117. package/library/recipes/assets/release-token-persistence.recipe.json +73 -0
  118. package/library/recipes/assets/release-token-search-import.recipe.json +40 -0
  119. package/library/recipes/assets/release-token-sorting.recipe.json +29 -0
  120. package/library/recipes/extension/networks/release-balance-stability.recipe.json +51 -0
  121. package/library/recipes/extension/perps/release-live-limit-order.recipe.json +104 -0
  122. package/library/recipes/extension/perps/release-order-entry.recipe.json +155 -0
  123. package/library/recipes/extension/perps/release-order-validation.recipe.json +220 -0
  124. package/library/recipes/extension/runner/action-validation.recipe.json +12 -1
  125. package/library/recipes/perps/release-activity-filters.recipe.json +51 -0
  126. package/library/recipes/perps/release-funds-flows.recipe.json +76 -0
  127. package/library/recipes/perps/release-live-market-order.recipe.json +122 -0
  128. package/library/recipes/perps/release-market-details.recipe.json +83 -0
  129. package/library/recipes/perps/release-market-search.recipe.json +79 -0
  130. package/library/recipes/perps/release-watchlist.recipe.json +99 -0
  131. package/library/recipes/swap-bridge/release-custom-slippage.recipe.json +90 -0
  132. package/library/recipes/swap-bridge/release-live-max-swap.recipe.json +99 -0
  133. package/library/recipes/swap-bridge/release-live-swap.recipe.json +101 -0
  134. package/library/recipes/swap-bridge/release-max-quote.recipe.json +72 -0
  135. package/library/recipes/swap-bridge/release-quote.recipe.json +72 -0
  136. package/library/recipes/swap-bridge/release-surface.recipe.json +43 -0
  137. package/library/recipes/wallet/import.recipe.json +20 -1
  138. package/library/recipes/wallet/reset-import.recipe.json +20 -1
  139. package/package.json +6 -5
  140. package/scripts/validate-release-report.mjs +319 -0
@@ -0,0 +1,265 @@
1
+ import { createAgentDeviceUiTransport } from '@farmslot/expo-recipe';
2
+ import { createAgentDeviceClient } from 'agent-device';
3
+ import { nativeSessionName } from '../platform/native-session-name.mjs';
4
+
5
+ const IDS = {
6
+ wallet: ['wallet-safe-area', 'homepage-container'],
7
+ walletTab: 'tab-bar-item-Wallet',
8
+ tradeTab: 'tab-bar-item-Trade',
9
+ perpsEntry: 'homepage-balance-breakdown-row-perps',
10
+ perpsTradeEntry: 'wallet-perps-action',
11
+ swapTradeEntry: 'wallet-actions-bottom-sheet-swap-button',
12
+ swapSourceInput: 'source-token-area-input',
13
+ swapSourceToken: 'source-token-area',
14
+ swapDestinationToken: 'dest-token-area',
15
+ perpsHome: 'perps-home',
16
+ perpsHomeBack: 'perps-home-back-button',
17
+ tutorialSkip: 'perps-tutorial-skip-button',
18
+ market: 'perps-market-details-view',
19
+ marketBack: 'perps-market-header-back-button',
20
+ marketSubtitle: 'perps-market-header-subtitle',
21
+ proMarket: 'perps-pro-market-scroll-view',
22
+ proMarketBack: 'perps-pro-market-header-back-button',
23
+ proMarketList: 'perps-pro-market-header-market-list-button',
24
+ proMarketSubtitle: 'perps-pro-market-header-subtitle',
25
+ marketList: 'perps-market-list',
26
+ };
27
+
28
+ export async function navigateOpaqueMobile(input, destination) {
29
+ const env = { ...process.env, ...(input.context?.env ?? {}) };
30
+ const device = String(env.ADB_SERIAL ?? env.ANDROID_SERIAL ?? '').trim();
31
+ if (!device) {
32
+ throw new Error('Opaque Mobile navigation requires one explicit Android device serial.');
33
+ }
34
+ const app = String(env.ANDROID_PACKAGE_ID ?? 'io.metamask');
35
+ const session = nativeSessionName(env, device, app);
36
+ const client = createAgentDeviceClient({
37
+ session,
38
+ lockPolicy: 'reject',
39
+ lockPlatform: 'android',
40
+ });
41
+ const selection = { platform: 'android', target: 'mobile', serial: device, session };
42
+ const transport = createAgentDeviceUiTransport({
43
+ platform: 'android',
44
+ device,
45
+ app,
46
+ session,
47
+ client,
48
+ });
49
+ const context = {
50
+ nodeId: String(input.context?.nodeId ?? 'navigate'),
51
+ projectRoot: input.context.projectRoot,
52
+ artifactsDir: input.context.artifactsDir,
53
+ env,
54
+ };
55
+ const press = (testId) => transport.execute('ui.press', {
56
+ test_id: testId,
57
+ timeout_ms: Number(input.node?.timeout_ms ?? 30_000),
58
+ }, context);
59
+ const wait = (testId) => transport.execute('ui.wait_for', {
60
+ test_id: testId,
61
+ expected: 'visible',
62
+ timeout_ms: Number(input.node?.timeout_ms ?? 30_000),
63
+ settle: false,
64
+ }, context);
65
+ const back = () => client.command.back({
66
+ platform: 'android',
67
+ target: 'mobile',
68
+ serial: device,
69
+ session,
70
+ mode: 'system',
71
+ responseLevel: 'digest',
72
+ });
73
+ const snapshot = () => client.capture.snapshot({
74
+ platform: 'android',
75
+ target: 'mobile',
76
+ serial: device,
77
+ session,
78
+ app,
79
+ interactiveOnly: false,
80
+ forceFull: true,
81
+ });
82
+ const identifiers = async () => new Set(
83
+ (await snapshot()).nodes.map((node) => String(node.identifier ?? '')),
84
+ );
85
+
86
+ try {
87
+ let observedScreen;
88
+ if (destination.page === 'home') {
89
+ await navigateHome({ identifiers, press, wait, back });
90
+ observedScreen = 'wallet-home';
91
+ } else if (destination.page === 'swap') {
92
+ observedScreen = await navigateSwap({ identifiers, press, wait, back });
93
+ } else if (destination.page === 'perps-market-list') {
94
+ observedScreen = await navigatePerpsMarketList({ identifiers, press, wait, back, timeoutMs: Number(input.node?.timeout_ms ?? 30_000) });
95
+ } else {
96
+ const timeoutMs = Number(input.node?.timeout_ms ?? 30_000);
97
+ const initialIds = await identifiers();
98
+ 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 });
101
+ if (destination.page === 'perps-market') {
102
+ const market = String(destination.params.market.symbol).toUpperCase();
103
+ const ids = await identifiers();
104
+ if (ids.has(IDS.proMarket) && !await marketMatches(snapshot, market)) {
105
+ await press(IDS.proMarketList);
106
+ observedScreen = await selectMarket({ destination, identifiers, press, snapshot, timeoutMs });
107
+ } else if (!ids.has(IDS.proMarket) && (!ids.has(IDS.market) || !await marketMatches(snapshot, market))) {
108
+ if (ids.has(IDS.market)) await press(IDS.marketBack);
109
+ await press(`perps-market-row-item-${market}`);
110
+ observedScreen = IDS.market;
111
+ }
112
+ await waitForPerpsDestination(identifiers, timeoutMs);
113
+ if (!await marketMatches(snapshot, market)) {
114
+ throw new Error(`Opaque Mobile navigation opened a different Perps market than ${market}.`);
115
+ }
116
+ }
117
+ }
118
+ return {
119
+ page: destination.page,
120
+ route: destination.route,
121
+ params: destination.params,
122
+ provider: 'android-native-accessibility',
123
+ proofPath: 'visible-native-navigation',
124
+ observedScreen,
125
+ };
126
+ } finally {
127
+ await closeNativeSession(client, selection);
128
+ }
129
+ }
130
+
131
+ async function navigateSwap({ identifiers, press, wait, back }) {
132
+ let ids = await identifiers();
133
+ if (ids.has(IDS.swapSourceInput)) return IDS.swapSourceInput;
134
+ if (!ids.has(IDS.swapTradeEntry)) {
135
+ await navigateHome({ identifiers, press, wait, back });
136
+ await press(IDS.tradeTab);
137
+ await wait(IDS.swapTradeEntry);
138
+ ids = await identifiers();
139
+ }
140
+ if (!ids.has(IDS.swapTradeEntry)) {
141
+ throw new Error('Opaque Mobile navigation could not find the visible Swap entry.');
142
+ }
143
+ await press(IDS.swapTradeEntry);
144
+ await wait(IDS.swapSourceInput);
145
+ await wait(IDS.swapSourceToken);
146
+ await wait(IDS.swapDestinationToken);
147
+ return IDS.swapSourceInput;
148
+ }
149
+
150
+ async function selectMarket({ destination, identifiers, press, snapshot, timeoutMs }) {
151
+ const market = String(destination.params.market.symbol).toUpperCase();
152
+ await press(`perps-market-row-item-${market}`);
153
+ const observed = await waitForPerpsDestination(identifiers, timeoutMs);
154
+ if (!await marketMatches(snapshot, market)) {
155
+ throw new Error(`Opaque Mobile navigation opened a different Perps market than ${market}.`);
156
+ }
157
+ return observed;
158
+ }
159
+
160
+ async function navigateHome({ identifiers, press, wait, back }) {
161
+ for (let attempt = 0; attempt < 4; attempt += 1) {
162
+ const ids = await identifiers();
163
+ if (IDS.wallet.some((id) => ids.has(id))) return;
164
+ if (ids.has(IDS.walletTab)) {
165
+ await press(IDS.walletTab);
166
+ await wait(IDS.wallet[0]);
167
+ return;
168
+ }
169
+ if (ids.has(IDS.marketBack)) {
170
+ await press(IDS.marketBack);
171
+ continue;
172
+ }
173
+ if (ids.has(IDS.proMarketBack)) {
174
+ await press(IDS.proMarketBack);
175
+ continue;
176
+ }
177
+ if (ids.has(IDS.perpsHomeBack)) {
178
+ await press(IDS.perpsHomeBack);
179
+ continue;
180
+ }
181
+ if (ids.has(IDS.perpsTradeEntry)) {
182
+ await back();
183
+ continue;
184
+ }
185
+ throw new Error('Opaque Mobile navigation could not find a visible route back to wallet home.');
186
+ }
187
+ throw new Error('Opaque Mobile navigation did not reach wallet home.');
188
+ }
189
+
190
+ async function navigatePerps({ identifiers, press, wait, back, timeoutMs }) {
191
+ let ids = await identifiers();
192
+ if (ids.has(IDS.proMarket)) return IDS.proMarket;
193
+ if (ids.has(IDS.market)) {
194
+ await press(IDS.marketBack);
195
+ ids = await identifiers();
196
+ }
197
+ if (!ids.has(IDS.perpsHome)) {
198
+ if (ids.has(IDS.perpsTradeEntry)) {
199
+ await press(IDS.perpsTradeEntry);
200
+ } else if (ids.has(IDS.perpsEntry)) {
201
+ await press(IDS.perpsEntry);
202
+ } else {
203
+ if (!ids.has(IDS.tradeTab)) {
204
+ await navigateHome({ identifiers, press, wait, back });
205
+ ids = await identifiers();
206
+ }
207
+ if (ids.has(IDS.tradeTab)) {
208
+ await press(IDS.tradeTab);
209
+ ids = await identifiers();
210
+ }
211
+ if (ids.has(IDS.perpsTradeEntry)) {
212
+ await press(IDS.perpsTradeEntry);
213
+ } else if (ids.has(IDS.perpsEntry)) {
214
+ await press(IDS.perpsEntry);
215
+ } else {
216
+ throw new Error('Opaque Mobile navigation could not find the visible Perps entry.');
217
+ }
218
+ }
219
+ ids = await identifiers();
220
+ }
221
+ if (ids.has(IDS.tutorialSkip)) await press(IDS.tutorialSkip);
222
+ return waitForPerpsDestination(identifiers, timeoutMs);
223
+ }
224
+
225
+ async function navigatePerpsMarketList({ identifiers, press, wait, back, timeoutMs }) {
226
+ let ids = await identifiers();
227
+ if (ids.has(IDS.marketList)) return IDS.marketList;
228
+ const observed = await navigatePerps({ identifiers, press, wait, back, timeoutMs });
229
+ ids = await identifiers();
230
+ if (ids.has(IDS.marketList)) return IDS.marketList;
231
+ if (ids.has(IDS.proMarketList)) {
232
+ await press(IDS.proMarketList);
233
+ await wait(IDS.marketList);
234
+ return IDS.marketList;
235
+ }
236
+ throw new Error(`Opaque Mobile navigation reached ${observed}, but no visible market-list control was available.`);
237
+ }
238
+
239
+ async function waitForPerpsDestination(identifiers, timeoutMs) {
240
+ const deadline = Date.now() + timeoutMs;
241
+ while (Date.now() <= deadline) {
242
+ const ids = await identifiers();
243
+ for (const id of [IDS.perpsHome, IDS.market, IDS.proMarket]) {
244
+ if (ids.has(id)) return id;
245
+ }
246
+ await new Promise((resolve) => setTimeout(resolve, 250));
247
+ }
248
+ throw new Error('Opaque Mobile navigation did not reach a visible Perps destination.');
249
+ }
250
+
251
+ async function closeNativeSession(client, selection) {
252
+ try {
253
+ await client.sessions.close({ ...selection, responseLevel: 'digest' });
254
+ } catch (error) {
255
+ if (!/No active session|SESSION_NOT_FOUND/iu.test(String(error?.message ?? error))) throw error;
256
+ }
257
+ }
258
+
259
+ async function marketMatches(snapshot, market) {
260
+ const nodes = (await snapshot()).nodes;
261
+ return nodes.some((node) =>
262
+ (node.identifier === IDS.marketSubtitle || node.identifier === IDS.proMarketSubtitle) &&
263
+ String(node.label ?? node.value ?? '').toUpperCase().includes(`${market}-USD`)
264
+ );
265
+ }
@@ -1,8 +1,11 @@
1
1
  import { navigate, runAdapter } from '../platform/bridge.mjs';
2
+ import { navigateOpaqueMobile } from './native-navigation.mjs';
2
3
 
3
4
  const PAGE_ROUTES = {
4
5
  home: { route: 'WalletView', params: {} },
5
6
  perps: { route: 'PerpsMarketListView', params: {} },
7
+ 'perps-market-list': { route: 'PerpsMarketListView', params: {} },
8
+ swap: { route: 'BridgeView', params: {} },
6
9
  };
7
10
 
8
11
  function text(value) {
@@ -18,12 +21,18 @@ function pageRoute(node) {
18
21
  if (!market) throw new Error('mobile ui.navigate page=perps-market requires market or symbol.');
19
22
  return { page, route: 'PerpsMarketDetails', params: { market: { symbol: market } } };
20
23
  }
21
- throw new Error('mobile ui.navigate supported page intents: home, perps, perps-market.');
24
+ throw new Error('mobile ui.navigate supported page intents: home, perps, perps-market-list, perps-market, swap.');
22
25
  }
23
26
 
24
27
  runAdapter(async (input) => {
25
28
  const alias = pageRoute(input.node);
26
29
  if (alias) {
30
+ if (
31
+ input.context?.env?.METAMASK_RECIPE_MOBILE_OPAQUE_RUNTIME === '1' ||
32
+ process.env.METAMASK_RECIPE_MOBILE_OPAQUE_RUNTIME === '1'
33
+ ) {
34
+ return navigateOpaqueMobile(input, alias);
35
+ }
27
36
  const navigation = await navigate(input, alias.route, alias.params);
28
37
  return { action: input.action, ...alias, navigation, proofPath: 'agentic-navigation' };
29
38
  }
@@ -6,6 +6,7 @@ import {
6
6
  selectBridgeStatusEntry,
7
7
  } from '../platform/bridge.mjs';
8
8
  import { walletFixturePath } from '../../harness-exports.mjs';
9
+ import { isOpaqueMobileRuntime, unlockWalletThroughNativeUi } from './native-ui.mjs';
9
10
 
10
11
  async function fixturePassword(projectRoot) {
11
12
  const candidates = [
@@ -156,6 +157,22 @@ async function waitForStableUnlocked(input, initialStatus, stableMs = 750) {
156
157
  }
157
158
 
158
159
  export async function ensureUnlocked(input) {
160
+ if (isOpaqueMobileRuntime(input)) {
161
+ const timeoutMs = Number(input.node?.unlock_timeout_ms ?? 30_000);
162
+ const password = input.node?.password ?? await fixturePassword(input.context.projectRoot);
163
+ const proof = await unlockWalletThroughNativeUi(input, String(password), timeoutMs);
164
+ return {
165
+ action: input.action,
166
+ unlocked: true,
167
+ alreadyUnlocked: proof.alreadyReady,
168
+ account: proof.selectedAddress || proof.selectedAddressObserved
169
+ ? { address: proof.selectedAddress ?? proof.selectedAddressObserved }
170
+ : null,
171
+ redacted: true,
172
+ provider: proof.provider,
173
+ proofPath: proof.proofPath,
174
+ };
175
+ }
159
176
  const targetTimeoutMs = Number(input.node?.target_timeout_ms ?? 20000);
160
177
  const targetDeadline = Date.now() + targetTimeoutMs;
161
178
  const targetStatus = await waitForTargetStatus(input, targetTimeoutMs);
@@ -10,6 +10,7 @@ import {
10
10
  } from '../platform/bridge.mjs';
11
11
  import { setMobileConsent } from '../analytics/consent-settings.mjs';
12
12
  import { ensureUnlocked } from './ensure_unlocked.mjs';
13
+ import { importWalletThroughNativeUi, isOpaqueMobileRuntime } from './native-ui.mjs';
13
14
 
14
15
  const TEST_IDS = {
15
16
  existingWallet: 'wallet-setup-screen-have-an-existing-wallet-button-id',
@@ -99,6 +100,15 @@ async function press(input, testId, timeoutMs) {
99
100
  return waitForCommand(input, ['press-test-id', testId], timeoutMs, testId);
100
101
  }
101
102
 
103
+ async function pressIfPresent(input, testId, timeoutMs = 2000) {
104
+ try {
105
+ await press(input, testId, Math.min(timeoutMs, 2000));
106
+ return true;
107
+ } catch {
108
+ return false;
109
+ }
110
+ }
111
+
102
112
  async function setSecretInput(input, testId, value, timeoutMs) {
103
113
  const directory = await mkdtemp(path.join(os.tmpdir(), 'mm-harness-wallet-import-'));
104
114
  const file = path.join(directory, 'value');
@@ -130,7 +140,7 @@ async function waitForInterestDecision(input, timeoutMs) {
130
140
  throw new Error(`Mobile onboarding did not reach the interests screen or wallet home from ${route}.`);
131
141
  }
132
142
 
133
- async function importThroughUi(input, timeoutMs) {
143
+ async function importThroughUi(input, timeoutMs, options) {
134
144
  const credentials = await resolveWalletImportCredentials(input);
135
145
  const metametricsEnabled = input.node?.metametrics === true || input.node?.metametrics === 'true';
136
146
  const interests = Array.isArray(input.node?.interests)
@@ -174,6 +184,12 @@ async function importThroughUi(input, timeoutMs) {
174
184
  if (String(selectedEntry(afterInterests, input)?.route?.name ?? '') === 'OnboardingSuccess') {
175
185
  await press(input, TEST_IDS.successDone, timeoutMs);
176
186
  }
187
+ if (!options.notificationsEnabled) {
188
+ await pressIfPresent(input, 'push-notification-new-user-sheet-button-not-now');
189
+ }
190
+ if (options.skipWalletTour) {
191
+ await pressIfPresent(input, 'wallet-home-onboarding-steps-skip');
192
+ }
177
193
 
178
194
  const deadline = Date.now() + timeoutMs;
179
195
  let observed = await status(input);
@@ -205,6 +221,56 @@ runAdapter(async (input) => {
205
221
  validateWalletImportOptions('mobile', input.node);
206
222
  const method = walletImportMethod(input);
207
223
  const timeoutMs = Number(input.node?.timeout_ms ?? 60000);
224
+ const biometricsEnabled = input.node?.biometrics === true || input.node?.biometrics === 'true';
225
+ const notificationsEnabled = input.node?.notifications === true || input.node?.notifications === 'true';
226
+ const skipWalletTour = input.node?.skip_wallet_tour !== false && input.node?.skip_wallet_tour !== 'false';
227
+ if (biometricsEnabled) {
228
+ throw new Error('Mobile biometrics=true requires manual device authentication and is not supported by an unattended recipe.');
229
+ }
230
+ if (isOpaqueMobileRuntime(input)) {
231
+ if (method === 'profile') {
232
+ throw new Error('metamask.wallet.import method=profile requires the Mobile development bridge; use method=auto or ui for a release artifact.');
233
+ }
234
+ const credentials = await resolveWalletImportCredentials(input);
235
+ const metametricsEnabled = input.node?.metametrics === true || input.node?.metametrics === 'true';
236
+ const interests = Array.isArray(input.node?.interests)
237
+ ? input.node.interests.map((value) => String(value))
238
+ : [];
239
+ const invalidInterest = interests.find((value) => !INTEREST_IDS.has(value));
240
+ if (invalidInterest) throw new Error(`Unsupported Mobile onboarding interest: ${invalidInterest}.`);
241
+ const proof = await importWalletThroughNativeUi(input, credentials, {
242
+ timeoutMs,
243
+ metametricsEnabled,
244
+ biometricsEnabled,
245
+ notificationsEnabled,
246
+ skipWalletTour,
247
+ interests,
248
+ requireFresh: method === 'ui',
249
+ });
250
+ if (!proof.addressVisible) {
251
+ throw new Error('Visible Mobile onboarding reached wallet home, but the imported account address was not observable through native accessibility.');
252
+ }
253
+ return {
254
+ action: input.action,
255
+ method: proof.alreadyReady ? 'native-profile' : 'native-ui-import',
256
+ credentialSource: credentials.source,
257
+ credentialSourceName: credentials.sourceName,
258
+ selectedAddress: proof.selectedAddress,
259
+ metametricsRequested: metametricsEnabled,
260
+ biometricsRequested: biometricsEnabled,
261
+ biometricPromptDismissed: proof.biometricPromptDismissed ?? false,
262
+ notificationsRequested: notificationsEnabled,
263
+ notificationPromptHandled: proof.notificationPromptHandled ?? false,
264
+ walletTourSkipped: proof.walletTourSkipped ?? false,
265
+ interests,
266
+ provider: proof.provider,
267
+ redacted: true,
268
+ proofPath: proof.proofPath,
269
+ };
270
+ }
271
+ if (notificationsEnabled) {
272
+ throw new Error('Mobile notifications=true requires native system-permission handling and is supported only for an Android release artifact.');
273
+ }
208
274
  const before = await status(input);
209
275
  const entry = selectedEntry(before, input);
210
276
  const address = selectedAddress(before, input);
@@ -218,7 +284,10 @@ runAdapter(async (input) => {
218
284
  if (method === 'profile') {
219
285
  throw new Error('metamask.wallet.import method=profile found a fresh Mobile onboarding state.');
220
286
  }
221
- const imported = await importThroughUi(input, timeoutMs);
287
+ const imported = await importThroughUi(input, timeoutMs, {
288
+ notificationsEnabled,
289
+ skipWalletTour,
290
+ });
222
291
  const importedEntry = selectedEntry(imported.observed, input);
223
292
  return {
224
293
  action: input.action,