@deeeed/metamask-harness 0.35.0 → 0.37.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 (73) hide show
  1. package/CHANGELOG.md +39 -0
  2. package/README.md +9 -1
  3. package/adapters/extension/artifact-runtime-state.cjs +128 -0
  4. package/adapters/extension/check-infura-readiness.cjs +102 -0
  5. package/adapters/extension/inject.mjs +2 -0
  6. package/adapters/extension/launch-browser.cjs +22 -0
  7. package/adapters/extension/live.sh +103 -18
  8. package/adapters/extension/readiness.mjs +77 -36
  9. package/adapters/extension/snapshot-dist.sh +88 -3
  10. package/adapters/extension/start-watch.sh +15 -0
  11. package/adapters/extension/verify.sh +6 -2
  12. package/adapters/extension/wallet-fixture-state.cjs +3 -1
  13. package/adapters/manifest.json +25 -1
  14. package/adapters/mobile/bridge-runtime/cdp-bridge.cjs +73 -42
  15. package/adapters/mobile/reset-app-data.sh +154 -0
  16. package/adapters/shared/log-tui.mjs +1 -1
  17. package/dist/adapters/extension/artifact-integrity.js +38 -0
  18. package/dist/adapters/extension/extension-id.js +23 -4
  19. package/dist/adapters/extension/product-config.js +29 -1
  20. package/dist/adapters/extension/release-artifact.js +386 -0
  21. package/dist/adapters/extension/runtime-decision.js +161 -20
  22. package/dist/adapters/extension/runtime.js +127 -0
  23. package/dist/adapters/mobile/release-artifact-state.js +124 -0
  24. package/dist/adapters/mobile/release-artifact.js +295 -0
  25. package/dist/adapters.js +29 -4
  26. package/dist/cli-commands.js +1 -1
  27. package/dist/cli.js +2 -2
  28. package/dist/command-contract.js +17 -1
  29. package/dist/commands/call.js +2 -1
  30. package/dist/commands/device-target.js +5 -0
  31. package/dist/commands/fixtures.js +106 -31
  32. package/dist/commands/launch/extension.js +92 -7
  33. package/dist/commands/launch/index.js +13 -0
  34. package/dist/commands/launch/mobile.js +2 -0
  35. package/dist/commands/provision.js +2 -0
  36. package/dist/commands/run-engine.js +89 -5
  37. package/dist/commands/run.js +3 -1
  38. package/dist/commands/runtime-launch.js +178 -10
  39. package/dist/heal-bounds.js +1 -1
  40. package/dist/live-adapter-contract.js +3 -1
  41. package/dist/metamask-action-validation.js +47 -1
  42. package/dist/mm-harness-cli.js +37 -4
  43. package/dist/recipe-security.js +3 -0
  44. package/dist/run-diagnostics.js +1 -1
  45. package/docs/RECIPES.md +29 -0
  46. package/docs/RELEASE-QA-CAPABILITY-MAP.md +150 -0
  47. package/library/actions/extension/perps/perps.mjs +2 -0
  48. package/library/actions/extension/perps/read_snapshot.mjs +470 -0
  49. package/library/actions/extension/platform/cdp.mjs +6 -3
  50. package/library/actions/extension/wallet/import.mjs +201 -0
  51. package/library/actions/extension/wallet/reset.mjs +98 -0
  52. package/library/actions/extension/wallet/secret-input.mjs +98 -0
  53. package/library/actions/extension/wallet/state.mjs +1 -0
  54. package/library/actions/mobile/analytics/consent-settings.mjs +112 -0
  55. package/library/actions/mobile/analytics/set_consent.mjs +4 -112
  56. package/library/actions/mobile/platform/bridge.mjs +8 -0
  57. package/library/actions/mobile/platform/observe-ui.mjs +84 -2
  58. package/library/actions/mobile/ui/native-navigation.mjs +225 -0
  59. package/library/actions/mobile/ui/navigate.mjs +7 -0
  60. package/library/actions/mobile/wallet/import.mjs +328 -0
  61. package/library/actions/mobile/wallet/native-ui.mjs +493 -0
  62. package/library/actions/mobile/wallet/read_state.mjs +16 -0
  63. package/library/actions/mobile/wallet/reset-helper.mjs +99 -0
  64. package/library/actions/mobile/wallet/reset.mjs +20 -0
  65. package/library/actions/shared/ui/locators.mjs +7 -0
  66. package/library/actions/shared/wallet/import-source.mjs +101 -0
  67. package/library/manifests/extension.action-manifest.json +228 -0
  68. package/library/manifests/mobile.action-manifest.json +124 -0
  69. package/library/recipes/extension/runner/action-validation.recipe.json +12 -1
  70. package/library/recipes/wallet/import.recipe.json +102 -0
  71. package/library/recipes/wallet/reset-import.recipe.json +107 -0
  72. package/package.json +1 -1
  73. package/scripts/completions.sh +2 -2
@@ -0,0 +1,225 @@
1
+ import { createAgentDeviceUiTransport } from '@farmslot/expo-recipe';
2
+ import { createAgentDeviceClient } from 'agent-device';
3
+
4
+ const IDS = {
5
+ wallet: ['wallet-safe-area', 'homepage-container'],
6
+ walletTab: 'tab-bar-item-Wallet',
7
+ tradeTab: 'tab-bar-item-Trade',
8
+ perpsEntry: 'homepage-balance-breakdown-row-perps',
9
+ perpsTradeEntry: 'wallet-perps-action',
10
+ perpsHome: 'perps-home',
11
+ perpsHomeBack: 'perps-home-back-button',
12
+ tutorialSkip: 'perps-tutorial-skip-button',
13
+ market: 'perps-market-details-view',
14
+ marketBack: 'perps-market-header-back-button',
15
+ marketSubtitle: 'perps-market-header-subtitle',
16
+ proMarket: 'perps-pro-market-scroll-view',
17
+ proMarketBack: 'perps-pro-market-header-back-button',
18
+ proMarketList: 'perps-pro-market-header-market-list-button',
19
+ proMarketSubtitle: 'perps-pro-market-header-subtitle',
20
+ marketList: 'perps-market-list',
21
+ };
22
+
23
+ export async function navigateOpaqueMobile(input, destination) {
24
+ const env = { ...process.env, ...(input.context?.env ?? {}) };
25
+ const device = String(env.ADB_SERIAL ?? env.ANDROID_SERIAL ?? '').trim();
26
+ if (!device) {
27
+ throw new Error('Opaque Mobile navigation requires one explicit Android device serial.');
28
+ }
29
+ const app = String(env.ANDROID_PACKAGE_ID ?? 'io.metamask');
30
+ const session = `mm-harness-navigation-${process.pid}-${String(input.context?.nodeId ?? 'navigate')}`
31
+ .replace(/[^A-Za-z0-9._-]/gu, '-');
32
+ const client = createAgentDeviceClient({
33
+ session,
34
+ lockPolicy: 'reject',
35
+ lockPlatform: 'android',
36
+ });
37
+ const selection = { platform: 'android', target: 'mobile', serial: device, session };
38
+ const transport = createAgentDeviceUiTransport({
39
+ platform: 'android',
40
+ device,
41
+ app,
42
+ session,
43
+ client,
44
+ });
45
+ const context = {
46
+ nodeId: String(input.context?.nodeId ?? 'navigate'),
47
+ projectRoot: input.context.projectRoot,
48
+ artifactsDir: input.context.artifactsDir,
49
+ env,
50
+ };
51
+ const press = (testId) => transport.execute('ui.press', {
52
+ test_id: testId,
53
+ timeout_ms: Number(input.node?.timeout_ms ?? 30_000),
54
+ }, context);
55
+ const wait = (testId) => transport.execute('ui.wait_for', {
56
+ test_id: testId,
57
+ expected: 'visible',
58
+ timeout_ms: Number(input.node?.timeout_ms ?? 30_000),
59
+ settle: false,
60
+ }, context);
61
+ const back = () => client.command.back({
62
+ platform: 'android',
63
+ target: 'mobile',
64
+ serial: device,
65
+ session,
66
+ mode: 'system',
67
+ responseLevel: 'digest',
68
+ });
69
+ const snapshot = () => client.capture.snapshot({
70
+ platform: 'android',
71
+ target: 'mobile',
72
+ serial: device,
73
+ session,
74
+ app,
75
+ interactiveOnly: false,
76
+ forceFull: true,
77
+ });
78
+ const identifiers = async () => new Set(
79
+ (await snapshot()).nodes.map((node) => String(node.identifier ?? '')),
80
+ );
81
+
82
+ try {
83
+ let observedScreen;
84
+ if (destination.page === 'home') {
85
+ await navigateHome({ identifiers, press, wait, back });
86
+ observedScreen = 'wallet-home';
87
+ } else {
88
+ const timeoutMs = Number(input.node?.timeout_ms ?? 30_000);
89
+ const initialIds = await identifiers();
90
+ observedScreen = destination.page === 'perps-market' && initialIds.has(IDS.marketList)
91
+ ? await selectMarket({ destination, identifiers, press, snapshot, timeoutMs })
92
+ : await navigatePerps({ identifiers, press, wait, back, timeoutMs });
93
+ if (destination.page === 'perps-market') {
94
+ const market = String(destination.params.market.symbol).toUpperCase();
95
+ const ids = await identifiers();
96
+ if (ids.has(IDS.proMarket) && !await marketMatches(snapshot, market)) {
97
+ await press(IDS.proMarketList);
98
+ observedScreen = await selectMarket({ destination, identifiers, press, snapshot, timeoutMs });
99
+ } else if (!ids.has(IDS.proMarket) && (!ids.has(IDS.market) || !await marketMatches(snapshot, market))) {
100
+ if (ids.has(IDS.market)) await press(IDS.marketBack);
101
+ await press(`perps-market-row-item-${market}`);
102
+ observedScreen = IDS.market;
103
+ }
104
+ await waitForPerpsDestination(identifiers, timeoutMs);
105
+ if (!await marketMatches(snapshot, market)) {
106
+ throw new Error(`Opaque Mobile navigation opened a different Perps market than ${market}.`);
107
+ }
108
+ }
109
+ }
110
+ return {
111
+ page: destination.page,
112
+ route: destination.route,
113
+ params: destination.params,
114
+ provider: 'android-native-accessibility',
115
+ proofPath: 'visible-native-navigation',
116
+ observedScreen,
117
+ };
118
+ } finally {
119
+ await closeNativeSession(transport, client, selection);
120
+ }
121
+ }
122
+
123
+ async function selectMarket({ destination, identifiers, press, snapshot, timeoutMs }) {
124
+ const market = String(destination.params.market.symbol).toUpperCase();
125
+ await press(`perps-market-row-item-${market}`);
126
+ const observed = await waitForPerpsDestination(identifiers, timeoutMs);
127
+ if (!await marketMatches(snapshot, market)) {
128
+ throw new Error(`Opaque Mobile navigation opened a different Perps market than ${market}.`);
129
+ }
130
+ return observed;
131
+ }
132
+
133
+ async function navigateHome({ identifiers, press, wait, back }) {
134
+ for (let attempt = 0; attempt < 4; attempt += 1) {
135
+ const ids = await identifiers();
136
+ if (IDS.wallet.some((id) => ids.has(id))) return;
137
+ if (ids.has(IDS.walletTab)) {
138
+ await press(IDS.walletTab);
139
+ await wait(IDS.wallet[0]);
140
+ return;
141
+ }
142
+ if (ids.has(IDS.marketBack)) {
143
+ await press(IDS.marketBack);
144
+ continue;
145
+ }
146
+ if (ids.has(IDS.proMarketBack)) {
147
+ await press(IDS.proMarketBack);
148
+ continue;
149
+ }
150
+ if (ids.has(IDS.perpsHomeBack)) {
151
+ await press(IDS.perpsHomeBack);
152
+ continue;
153
+ }
154
+ if (ids.has(IDS.perpsTradeEntry)) {
155
+ await back();
156
+ continue;
157
+ }
158
+ throw new Error('Opaque Mobile navigation could not find a visible route back to wallet home.');
159
+ }
160
+ throw new Error('Opaque Mobile navigation did not reach wallet home.');
161
+ }
162
+
163
+ async function navigatePerps({ identifiers, press, wait, back, timeoutMs }) {
164
+ let ids = await identifiers();
165
+ if (ids.has(IDS.proMarket)) return IDS.proMarket;
166
+ if (ids.has(IDS.market)) {
167
+ await press(IDS.marketBack);
168
+ ids = await identifiers();
169
+ }
170
+ if (!ids.has(IDS.perpsHome)) {
171
+ if (ids.has(IDS.perpsTradeEntry)) {
172
+ await press(IDS.perpsTradeEntry);
173
+ } else if (ids.has(IDS.perpsEntry)) {
174
+ await press(IDS.perpsEntry);
175
+ } else {
176
+ if (!ids.has(IDS.tradeTab)) {
177
+ await navigateHome({ identifiers, press, wait, back });
178
+ ids = await identifiers();
179
+ }
180
+ if (ids.has(IDS.tradeTab)) {
181
+ await press(IDS.tradeTab);
182
+ ids = await identifiers();
183
+ }
184
+ if (ids.has(IDS.perpsTradeEntry)) {
185
+ await press(IDS.perpsTradeEntry);
186
+ } else if (ids.has(IDS.perpsEntry)) {
187
+ await press(IDS.perpsEntry);
188
+ } else {
189
+ throw new Error('Opaque Mobile navigation could not find the visible Perps entry.');
190
+ }
191
+ }
192
+ ids = await identifiers();
193
+ }
194
+ if (ids.has(IDS.tutorialSkip)) await press(IDS.tutorialSkip);
195
+ return waitForPerpsDestination(identifiers, timeoutMs);
196
+ }
197
+
198
+ async function waitForPerpsDestination(identifiers, timeoutMs) {
199
+ const deadline = Date.now() + timeoutMs;
200
+ while (Date.now() <= deadline) {
201
+ const ids = await identifiers();
202
+ for (const id of [IDS.perpsHome, IDS.market, IDS.proMarket]) {
203
+ if (ids.has(id)) return id;
204
+ }
205
+ await new Promise((resolve) => setTimeout(resolve, 250));
206
+ }
207
+ throw new Error('Opaque Mobile navigation did not reach a visible Perps destination.');
208
+ }
209
+
210
+ async function closeNativeSession(transport, client, selection) {
211
+ await transport.close();
212
+ try {
213
+ await client.sessions.close({ ...selection, responseLevel: 'digest' });
214
+ } catch (error) {
215
+ if (!/No active session|SESSION_NOT_FOUND/iu.test(String(error?.message ?? error))) throw error;
216
+ }
217
+ }
218
+
219
+ async function marketMatches(snapshot, market) {
220
+ const nodes = (await snapshot()).nodes;
221
+ return nodes.some((node) =>
222
+ (node.identifier === IDS.marketSubtitle || node.identifier === IDS.proMarketSubtitle) &&
223
+ String(node.label ?? node.value ?? '').toUpperCase().includes(`${market}-USD`)
224
+ );
225
+ }
@@ -1,4 +1,5 @@
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: {} },
@@ -24,6 +25,12 @@ function pageRoute(node) {
24
25
  runAdapter(async (input) => {
25
26
  const alias = pageRoute(input.node);
26
27
  if (alias) {
28
+ if (
29
+ input.context?.env?.METAMASK_RECIPE_MOBILE_OPAQUE_RUNTIME === '1' ||
30
+ process.env.METAMASK_RECIPE_MOBILE_OPAQUE_RUNTIME === '1'
31
+ ) {
32
+ return navigateOpaqueMobile(input, alias);
33
+ }
27
34
  const navigation = await navigate(input, alias.route, alias.params);
28
35
  return { action: input.action, ...alias, navigation, proofPath: 'agentic-navigation' };
29
36
  }
@@ -0,0 +1,328 @@
1
+ import { mkdtemp, rm, writeFile } from 'node:fs/promises';
2
+ import os from 'node:os';
3
+ import path from 'node:path';
4
+
5
+ import { resolveWalletImportCredentials, validateWalletImportOptions, walletImportMethod } from '../../shared/wallet/import-source.mjs';
6
+ import {
7
+ bridgeCommand,
8
+ runAdapter,
9
+ selectBridgeStatusEntry,
10
+ } from '../platform/bridge.mjs';
11
+ import { setMobileConsent } from '../analytics/consent-settings.mjs';
12
+ import { ensureUnlocked } from './ensure_unlocked.mjs';
13
+ import { importWalletThroughNativeUi, isOpaqueMobileRuntime } from './native-ui.mjs';
14
+
15
+ const TEST_IDS = {
16
+ existingWallet: 'wallet-setup-screen-have-an-existing-wallet-button-id',
17
+ importSeed: 'onboarding-sheet-import-seed-button-id',
18
+ srp: 'phrase-input-id',
19
+ continue: 'import-from-seed-screen-continue-button-id',
20
+ password: 'create-password-first-input-field',
21
+ confirmPassword: 'create-password-second-input-field',
22
+ passwordTerms: 'password-understand-box',
23
+ createPassword: 'submit-button',
24
+ metricsCheckbox: 'optin-metrics-metrics-checkbox',
25
+ metricsContinue: 'optin-metrics-continue-button-id',
26
+ interestSkip: 'onboarding-interest-skip-button',
27
+ interestContinue: 'onboarding-interest-continue-button',
28
+ successDone: 'onboarding-success-done-button',
29
+ };
30
+
31
+ const INTEREST_IDS = new Set([
32
+ 'swap_tokens',
33
+ 'trade_perpetuals',
34
+ 'prediction_markets',
35
+ 'send_receive_crypto',
36
+ 'earn_and_spend',
37
+ 'use_other_crypto_apps',
38
+ ]);
39
+
40
+ function selectedEntry(status, input) {
41
+ return selectBridgeStatusEntry(status, input) ?? {};
42
+ }
43
+
44
+ function selectedAddress(status, input) {
45
+ return String(selectedEntry(status, input)?.account?.address ?? '').toLowerCase();
46
+ }
47
+
48
+ function walletReady(observed, input) {
49
+ const route = String(selectedEntry(observed, input)?.route?.name ?? '');
50
+ return Boolean(
51
+ selectedAddress(observed, input) &&
52
+ route &&
53
+ route !== 'Login' &&
54
+ route !== 'Onboarding' &&
55
+ route !== 'OptinMetrics' &&
56
+ route !== 'OnboardingInterestQuestionnaire'
57
+ && route !== 'OnboardingSuccess'
58
+ );
59
+ }
60
+
61
+ async function waitForPostPasswordScreen(input, timeoutMs) {
62
+ const deadline = Date.now() + timeoutMs;
63
+ let observed;
64
+ while (Date.now() <= deadline) {
65
+ observed = await status(input);
66
+ const route = String(selectedEntry(observed, input)?.route?.name ?? '');
67
+ if (
68
+ route === 'OptinMetrics' ||
69
+ route === 'OnboardingInterestQuestionnaire' ||
70
+ route === 'OnboardingSuccess' ||
71
+ walletReady(observed, input)
72
+ ) return { route, observed };
73
+ await new Promise((resolve) => setTimeout(resolve, 250));
74
+ }
75
+ const route = String(selectedEntry(observed, input)?.route?.name ?? 'unknown');
76
+ throw new Error(`Mobile onboarding did not advance after password creation from ${route}.`);
77
+ }
78
+
79
+ async function status(input) {
80
+ return bridgeCommand(input, ['status-selected']);
81
+ }
82
+
83
+ async function waitForCommand(input, args, timeoutMs, label) {
84
+ const deadline = Date.now() + timeoutMs;
85
+ let lastError;
86
+ while (Date.now() <= deadline) {
87
+ try {
88
+ const result = await bridgeCommand(input, args);
89
+ if (result?.ok !== false) return result;
90
+ lastError = result?.error;
91
+ } catch (error) {
92
+ lastError = error;
93
+ }
94
+ await new Promise((resolve) => setTimeout(resolve, 250));
95
+ }
96
+ throw new Error(`${label} was not available before the wallet import timeout: ${String(lastError?.message ?? lastError ?? 'unknown')}`);
97
+ }
98
+
99
+ async function press(input, testId, timeoutMs) {
100
+ return waitForCommand(input, ['press-test-id', testId], timeoutMs, testId);
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
+
112
+ async function setSecretInput(input, testId, value, timeoutMs) {
113
+ const directory = await mkdtemp(path.join(os.tmpdir(), 'mm-harness-wallet-import-'));
114
+ const file = path.join(directory, 'value');
115
+ try {
116
+ await writeFile(file, value, { mode: 0o600 });
117
+ return await waitForCommand(
118
+ input,
119
+ ['set-input-file', testId, file],
120
+ timeoutMs,
121
+ testId,
122
+ );
123
+ } finally {
124
+ await rm(directory, { recursive: true, force: true });
125
+ }
126
+ }
127
+
128
+ async function waitForInterestDecision(input, timeoutMs) {
129
+ const deadline = Date.now() + Math.min(timeoutMs, 10000);
130
+ let observed;
131
+ while (Date.now() <= deadline) {
132
+ observed = await status(input);
133
+ const route = String(selectedEntry(observed, input)?.route?.name ?? '');
134
+ if (route === 'OnboardingInterestQuestionnaire') return { available: true, observed };
135
+ if (route === 'OnboardingSuccess') return { available: false, observed };
136
+ if (walletReady(observed, input)) return { available: false, observed };
137
+ await new Promise((resolve) => setTimeout(resolve, 250));
138
+ }
139
+ const route = String(selectedEntry(observed, input)?.route?.name ?? 'unknown');
140
+ throw new Error(`Mobile onboarding did not reach the interests screen or wallet home from ${route}.`);
141
+ }
142
+
143
+ async function importThroughUi(input, timeoutMs, options) {
144
+ const credentials = await resolveWalletImportCredentials(input);
145
+ const metametricsEnabled = input.node?.metametrics === true || input.node?.metametrics === 'true';
146
+ const interests = Array.isArray(input.node?.interests)
147
+ ? input.node.interests.map((value) => String(value))
148
+ : [];
149
+ const invalidInterest = interests.find((value) => !INTEREST_IDS.has(value));
150
+ if (invalidInterest) throw new Error(`Unsupported Mobile onboarding interest: ${invalidInterest}.`);
151
+ await press(input, TEST_IDS.existingWallet, timeoutMs);
152
+ try {
153
+ await setSecretInput(input, TEST_IDS.srp, credentials.mnemonic, 3000);
154
+ } catch {
155
+ await press(input, TEST_IDS.importSeed, timeoutMs);
156
+ await setSecretInput(input, TEST_IDS.srp, credentials.mnemonic, timeoutMs);
157
+ }
158
+ await press(input, TEST_IDS.continue, timeoutMs);
159
+ await setSecretInput(input, TEST_IDS.password, credentials.password, timeoutMs);
160
+ await setSecretInput(input, TEST_IDS.confirmPassword, credentials.password, timeoutMs);
161
+ await press(input, TEST_IDS.passwordTerms, timeoutMs);
162
+ await press(input, TEST_IDS.createPassword, timeoutMs);
163
+
164
+ const postPassword = await waitForPostPasswordScreen(input, timeoutMs);
165
+ if (postPassword.route === 'OptinMetrics') {
166
+ if (!metametricsEnabled) await press(input, TEST_IDS.metricsCheckbox, timeoutMs);
167
+ await press(input, TEST_IDS.metricsContinue, timeoutMs);
168
+ }
169
+
170
+ const interestDecision = await waitForInterestDecision(input, timeoutMs);
171
+ if (!interestDecision.available && interests.length > 0) {
172
+ throw new Error('Mobile onboarding interests were requested, but the questionnaire is not enabled for this build.');
173
+ }
174
+ if (interestDecision.available && interests.length > 0) {
175
+ for (const interest of interests) {
176
+ await press(input, `onboarding-interest-option-${interest}`, timeoutMs);
177
+ }
178
+ await press(input, TEST_IDS.interestContinue, timeoutMs);
179
+ } else if (interestDecision.available) {
180
+ await press(input, TEST_IDS.interestSkip, timeoutMs);
181
+ }
182
+
183
+ const afterInterests = await status(input);
184
+ if (String(selectedEntry(afterInterests, input)?.route?.name ?? '') === 'OnboardingSuccess') {
185
+ await press(input, TEST_IDS.successDone, timeoutMs);
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
+ }
193
+
194
+ const deadline = Date.now() + timeoutMs;
195
+ let observed = await status(input);
196
+ while (Date.now() <= deadline) {
197
+ if (walletReady(observed, input)) break;
198
+ await new Promise((resolve) => setTimeout(resolve, 250));
199
+ observed = await status(input);
200
+ }
201
+ const actualAddress = selectedAddress(observed, input);
202
+ if (actualAddress !== credentials.expectedAddress) {
203
+ throw new Error(`Wallet import selected ${actualAddress || 'no address'}, expected ${credentials.expectedAddress}.`);
204
+ }
205
+ const { consent } = await setMobileConsent(
206
+ input,
207
+ metametricsEnabled,
208
+ false,
209
+ Math.min(timeoutMs, 30000),
210
+ );
211
+ const actualMetametricsEnabled = consent.optedIn === true;
212
+ await bridgeCommand(input, ['navigate', 'WalletView']);
213
+ observed = await status(input);
214
+ if (!walletReady(observed, input)) {
215
+ throw new Error('Mobile wallet import could not return to wallet home after applying MetaMetrics consent.');
216
+ }
217
+ return { credentials, observed, metametricsEnabled: actualMetametricsEnabled, interests };
218
+ }
219
+
220
+ runAdapter(async (input) => {
221
+ validateWalletImportOptions('mobile', input.node);
222
+ const method = walletImportMethod(input);
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
+ }
274
+ const before = await status(input);
275
+ const entry = selectedEntry(before, input);
276
+ const address = selectedAddress(before, input);
277
+ const route = String(entry?.route?.name ?? '');
278
+ const onboarding = route === 'Onboarding' || (!address && route !== 'Login');
279
+
280
+ if (method === 'ui' && !onboarding) {
281
+ throw new Error('metamask.wallet.import method=ui requires a fresh Mobile onboarding state.');
282
+ }
283
+ if (onboarding) {
284
+ if (method === 'profile') {
285
+ throw new Error('metamask.wallet.import method=profile found a fresh Mobile onboarding state.');
286
+ }
287
+ const imported = await importThroughUi(input, timeoutMs, {
288
+ notificationsEnabled,
289
+ skipWalletTour,
290
+ });
291
+ const importedEntry = selectedEntry(imported.observed, input);
292
+ return {
293
+ action: input.action,
294
+ method: 'ui-import',
295
+ credentialSource: imported.credentials.source,
296
+ credentialSourceName: imported.credentials.sourceName,
297
+ selectedAddress: importedEntry.account.address,
298
+ route: importedEntry.route ?? null,
299
+ metametricsEnabled: imported.metametricsEnabled,
300
+ interests: imported.interests,
301
+ redacted: true,
302
+ proofPath: 'mobile-visible-onboarding-import',
303
+ };
304
+ }
305
+
306
+ const credentials = await resolveWalletImportCredentials(input);
307
+ const unlocked = await ensureUnlocked({
308
+ ...input,
309
+ node: {
310
+ ...input.node,
311
+ ...(route === 'Login' ? { password: credentials.password } : {}),
312
+ },
313
+ });
314
+ const actualAddress = String(unlocked.account?.address ?? address).toLowerCase();
315
+ if (actualAddress !== credentials.expectedAddress) {
316
+ throw new Error(`Wallet profile selected ${actualAddress || 'no address'}, expected ${credentials.expectedAddress}.`);
317
+ }
318
+ return {
319
+ action: input.action,
320
+ method: 'profile',
321
+ credentialSource: credentials.source,
322
+ credentialSourceName: credentials.sourceName,
323
+ selectedAddress: unlocked.account?.address ?? address,
324
+ route: unlocked.route ?? entry.route ?? null,
325
+ redacted: true,
326
+ proofPath: 'mobile-fixture-profile',
327
+ };
328
+ });