@deeeed/metamask-harness 0.36.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 (51) hide show
  1. package/CHANGELOG.md +24 -0
  2. package/adapters/extension/artifact-runtime-state.cjs +128 -0
  3. package/adapters/extension/inject.mjs +1 -0
  4. package/adapters/extension/launch-browser.cjs +22 -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 +9 -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 +161 -20
  15. package/dist/adapters/extension/runtime.js +127 -0
  16. package/dist/adapters/mobile/release-artifact-state.js +124 -0
  17. package/dist/adapters/mobile/release-artifact.js +295 -0
  18. package/dist/adapters.js +11 -4
  19. package/dist/command-contract.js +16 -0
  20. package/dist/commands/call.js +2 -1
  21. package/dist/commands/launch/extension.js +55 -6
  22. package/dist/commands/launch/mobile.js +2 -0
  23. package/dist/commands/provision.js +2 -0
  24. package/dist/commands/run-engine.js +89 -5
  25. package/dist/commands/run.js +3 -1
  26. package/dist/commands/runtime-launch.js +178 -10
  27. package/dist/heal-bounds.js +1 -1
  28. package/dist/live-adapter-contract.js +3 -1
  29. package/dist/metamask-action-validation.js +47 -1
  30. package/dist/mm-harness-cli.js +31 -1
  31. package/dist/run-diagnostics.js +1 -1
  32. package/docs/RELEASE-QA-CAPABILITY-MAP.md +150 -0
  33. package/library/actions/extension/perps/perps.mjs +2 -0
  34. package/library/actions/extension/perps/read_snapshot.mjs +470 -0
  35. package/library/actions/extension/platform/cdp.mjs +6 -3
  36. package/library/actions/extension/wallet/import.mjs +13 -46
  37. package/library/actions/extension/wallet/secret-input.mjs +98 -0
  38. package/library/actions/mobile/platform/observe-ui.mjs +84 -2
  39. package/library/actions/mobile/ui/native-navigation.mjs +225 -0
  40. package/library/actions/mobile/ui/navigate.mjs +7 -0
  41. package/library/actions/mobile/wallet/import.mjs +71 -2
  42. package/library/actions/mobile/wallet/native-ui.mjs +493 -0
  43. package/library/actions/mobile/wallet/read_state.mjs +16 -0
  44. package/library/actions/mobile/wallet/reset.mjs +17 -4
  45. package/library/actions/shared/ui/locators.mjs +7 -0
  46. package/library/manifests/extension.action-manifest.json +116 -0
  47. package/library/manifests/mobile.action-manifest.json +16 -0
  48. package/library/recipes/extension/runner/action-validation.recipe.json +12 -1
  49. package/library/recipes/wallet/import.recipe.json +20 -1
  50. package/library/recipes/wallet/reset-import.recipe.json +20 -1
  51. package/package.json +1 -1
@@ -1,5 +1,6 @@
1
1
  import { resolveWalletImportCredentials, validateWalletImportOptions, walletImportMethod } from '../../shared/wallet/import-source.mjs';
2
2
  import { runAdapter, withExtensionPage } from '../platform/cdp.mjs';
3
+ import { pasteSecretRecoveryPhrase, setSecretInput } from './secret-input.mjs';
3
4
  import { readWalletStateExpression } from './state.mjs';
4
5
 
5
6
  const SELECTORS = {
@@ -54,42 +55,6 @@ async function waitForFirstSelector(page, selectors, timeoutMs) {
54
55
  throw new Error('Wallet import timed out waiting for the next onboarding screen.');
55
56
  }
56
57
 
57
- async function setSecretInput(page, selector, value) {
58
- await page.evaluate(`(() => {
59
- const element = document.querySelector(${JSON.stringify(selector)});
60
- if (!element) throw new Error('Wallet import secret input was not found.');
61
- element.focus();
62
- if (typeof element.select === 'function') element.select();
63
- })()`);
64
- await page.session.call('Input.dispatchKeyEvent', { type: 'keyDown', key: 'Backspace' });
65
- await page.session.call('Input.dispatchKeyEvent', { type: 'keyUp', key: 'Backspace' });
66
- await page.session.call('Input.insertText', { text: value });
67
- const matches = await page.evaluate(`(() => {
68
- const element = document.querySelector(${JSON.stringify(selector)});
69
- if (!element) return false;
70
- element.dispatchEvent(new Event('change', { bubbles: true }));
71
- return element.value === ${JSON.stringify(value)};
72
- })()`);
73
- if (!matches) throw new Error('Wallet import secret input did not retain the supplied value.');
74
- }
75
-
76
- async function pasteSecretInput(page, selector, value) {
77
- const pasted = await page.evaluate(`(() => {
78
- const element = document.querySelector(${JSON.stringify(selector)});
79
- if (!element) return false;
80
- const clipboardData = new DataTransfer();
81
- clipboardData.setData('text', ${JSON.stringify(value)});
82
- element.focus();
83
- element.dispatchEvent(new ClipboardEvent('paste', {
84
- bubbles: true,
85
- cancelable: true,
86
- clipboardData,
87
- }));
88
- return true;
89
- })()`);
90
- if (!pasted) throw new Error('Wallet import Secret Recovery Phrase input was not found.');
91
- }
92
-
93
58
  async function waitForEnabled(page, selector, timeoutMs) {
94
59
  const deadline = Date.now() + timeoutMs;
95
60
  while (Date.now() <= deadline) {
@@ -120,17 +85,19 @@ async function importThroughUi(page, input, timeoutMs) {
120
85
  if (interests.length > 0) {
121
86
  throw new Error('metamask.wallet.import interests are supported only on Mobile.');
122
87
  }
123
- await page.click(SELECTORS.importWallet);
124
- const importChoice = await waitForFirstSelector(
125
- page,
126
- [SELECTORS.importWithSrp, SELECTORS.srp],
127
- timeoutMs,
128
- );
129
- if (importChoice === SELECTORS.importWithSrp) {
130
- await page.click(SELECTORS.importWithSrp);
88
+ if (!(await hasSelector(page, SELECTORS.srp))) {
89
+ await page.click(SELECTORS.importWallet);
90
+ const importChoice = await waitForFirstSelector(
91
+ page,
92
+ [SELECTORS.importWithSrp, SELECTORS.srp],
93
+ timeoutMs,
94
+ );
95
+ if (importChoice === SELECTORS.importWithSrp) {
96
+ await page.click(SELECTORS.importWithSrp);
97
+ }
98
+ await waitForSelector(page, SELECTORS.srp, timeoutMs);
131
99
  }
132
- await waitForSelector(page, SELECTORS.srp, timeoutMs);
133
- await pasteSecretInput(page, SELECTORS.srp, credentials.mnemonic);
100
+ await pasteSecretRecoveryPhrase(page, SELECTORS.srp, credentials.mnemonic, timeoutMs);
134
101
  await waitForEnabled(page, SELECTORS.confirmSrp, timeoutMs);
135
102
  await page.click(SELECTORS.confirmSrp);
136
103
  await waitForSelector(page, SELECTORS.password, timeoutMs);
@@ -0,0 +1,98 @@
1
+ export async function setSecretInput(page, selector, value) {
2
+ const found = await page.evaluate(`(() => {
3
+ const element = document.querySelector(${JSON.stringify(selector)});
4
+ if (!element) return false;
5
+ element.focus();
6
+ if (typeof element.select === 'function') element.select();
7
+ return true;
8
+ })()`);
9
+ if (!found) throw new Error('Wallet import secret input was not found.');
10
+
11
+ await page.session.call('Input.dispatchKeyEvent', { type: 'keyDown', key: 'Backspace' });
12
+ await page.session.call('Input.dispatchKeyEvent', { type: 'keyUp', key: 'Backspace' });
13
+ await page.session.call('Input.insertText', { text: value });
14
+
15
+ const retainedLength = await page.evaluate(`(() => {
16
+ const element = document.querySelector(${JSON.stringify(selector)});
17
+ if (!element) return -1;
18
+ element.dispatchEvent(new Event('change', { bubbles: true }));
19
+ return typeof element.value === 'string' ? element.value.length : -1;
20
+ })()`);
21
+ if (retainedLength !== value.length) {
22
+ throw new Error('Wallet import secret input did not retain the supplied value.');
23
+ }
24
+ }
25
+
26
+ export async function pasteSecretRecoveryPhrase(page, selector, value, timeoutMs) {
27
+ const bridgeKey = 'mm-harness.wallet.recovery-phrase-paste';
28
+ const preload = await page.session.call('Page.addScriptToEvaluateOnNewDocument', {
29
+ source: `(() => {
30
+ const bridgeKey = Symbol.for(${JSON.stringify(bridgeKey)});
31
+ const NativeDataTransfer = globalThis.DataTransfer;
32
+ const NativeClipboardEvent = globalThis.ClipboardEvent;
33
+ Object.defineProperty(globalThis, bridgeKey, {
34
+ configurable: true,
35
+ value(selector) {
36
+ const element = document.querySelector(selector);
37
+ if (!element || typeof element.value !== 'string') return false;
38
+ const clipboardData = new NativeDataTransfer();
39
+ clipboardData.setData('text', element.value);
40
+ element.focus();
41
+ element.dispatchEvent(new NativeClipboardEvent('paste', {
42
+ bubbles: true,
43
+ cancelable: true,
44
+ clipboardData,
45
+ }));
46
+ return true;
47
+ },
48
+ });
49
+ })();`,
50
+ });
51
+ if (typeof preload?.identifier !== 'string' || preload.identifier.length === 0) {
52
+ throw new Error('Wallet import recovery phrase preload was not installed.');
53
+ }
54
+
55
+ let actionError;
56
+ try {
57
+ await page.session.call('Page.reload', { ignoreCache: false });
58
+ const deadline = Date.now() + timeoutMs;
59
+ while (Date.now() <= deadline) {
60
+ const found = await page.evaluate(
61
+ `Boolean(document.querySelector(${JSON.stringify(selector)}))`,
62
+ ).catch(() => false);
63
+ if (found) break;
64
+ await new Promise((resolve) => setTimeout(resolve, 100));
65
+ }
66
+ await setSecretInput(page, selector, value);
67
+ const pasted = await page.evaluate(`(() => {
68
+ const bridge = globalThis[Symbol.for(${JSON.stringify(bridgeKey)})];
69
+ return typeof bridge === 'function' && bridge(${JSON.stringify(selector)});
70
+ })()`);
71
+ if (!pasted) throw new Error('Wallet import Secret Recovery Phrase input was not found.');
72
+ } catch (error) {
73
+ actionError = error;
74
+ }
75
+
76
+ const cleanupErrors = [];
77
+ try {
78
+ const removed = await page.evaluate(`(() => {
79
+ const key = Symbol.for(${JSON.stringify(bridgeKey)});
80
+ delete globalThis[key];
81
+ return !(key in globalThis);
82
+ })()`);
83
+ if (removed !== true) cleanupErrors.push('page bridge removal was not confirmed');
84
+ } catch (error) {
85
+ cleanupErrors.push(error instanceof Error ? error.message : String(error));
86
+ }
87
+ try {
88
+ await page.session.call('Page.removeScriptToEvaluateOnNewDocument', {
89
+ identifier: preload.identifier,
90
+ });
91
+ } catch (error) {
92
+ cleanupErrors.push(error instanceof Error ? error.message : String(error));
93
+ }
94
+ if (cleanupErrors.length > 0) {
95
+ throw new Error(`Wallet import recovery phrase bridge cleanup failed: ${cleanupErrors.join('; ')}.`);
96
+ }
97
+ if (actionError) throw actionError;
98
+ }
@@ -1,12 +1,13 @@
1
1
  import { execFile } from 'node:child_process';
2
2
  import { promisify } from 'node:util';
3
+ import { createAgentDeviceClient } from 'agent-device';
3
4
 
4
5
  import { bridgeCommand } from './bridge.mjs';
5
6
  import { mobileToolRecovery, resolveMobileToolPath } from './tool-paths.mjs';
6
7
 
7
8
  const execFileAsync = promisify(execFile);
8
- const VISIBLE_LIMIT = 50;
9
- const HIDDEN_LIMIT = 50;
9
+ const VISIBLE_LIMIT = 200;
10
+ const HIDDEN_LIMIT = 100;
10
11
 
11
12
  export async function observeNativeUi(payload, context) {
12
13
  await hideAccessibilityMaskingHud(payload, context);
@@ -111,6 +112,9 @@ async function readAndroidHierarchy(node, env) {
111
112
  );
112
113
  }
113
114
  const serial = await resolveAndroidSerial(adbPath, node, env);
115
+ if (isOpaqueRuntime(env)) {
116
+ return readAndroidAgentDeviceHierarchy(serial, env);
117
+ }
114
118
  const { stdout } = await execFileAsync(
115
119
  adbPath,
116
120
  ['-s', serial, 'exec-out', 'uiautomator', 'dump', '/dev/tty'],
@@ -135,6 +139,84 @@ async function readAndroidHierarchy(node, env) {
135
139
  );
136
140
  }
137
141
 
142
+ async function readAndroidAgentDeviceHierarchy(serial, env) {
143
+ const session = `mm-harness-observe-${process.pid}-${Date.now()}`;
144
+ const client = createAgentDeviceClient({
145
+ session,
146
+ lockPolicy: 'reject',
147
+ lockPlatform: 'android',
148
+ });
149
+ try {
150
+ const snapshot = await client.capture.snapshot({
151
+ platform: 'android',
152
+ target: 'mobile',
153
+ serial,
154
+ session,
155
+ app: text(env.ANDROID_PACKAGE_ID) ?? 'io.metamask',
156
+ interactiveOnly: false,
157
+ forceFull: true,
158
+ });
159
+ const hierarchy = flattenAgentDeviceHierarchy(
160
+ snapshot.nodes,
161
+ text(env.ANDROID_PACKAGE_ID) ?? 'io.metamask',
162
+ );
163
+ if (hierarchy.nodeCount === 0) {
164
+ throw new Error('Agent Device returned an empty Android accessibility hierarchy.');
165
+ }
166
+ return normalizedHierarchy(
167
+ 'agent-device-accessibility',
168
+ 'android',
169
+ serial,
170
+ hierarchy.visible,
171
+ hierarchy.hidden,
172
+ hierarchy,
173
+ );
174
+ } finally {
175
+ await client.sessions.close({ session, shutdown: false });
176
+ }
177
+ }
178
+
179
+ function flattenAgentDeviceHierarchy(nodes, appId) {
180
+ const visible = [];
181
+ const hidden = [];
182
+ let screenName;
183
+ for (const source of Array.isArray(nodes) ? nodes : []) {
184
+ const node = record(source);
185
+ const bundleId = text(node.bundleId);
186
+ if (bundleId && bundleId !== appId) continue;
187
+ const bounds = normalizeBounds(node.rect ?? node.bounds);
188
+ const label = text(node.label) ?? text(node.value);
189
+ const role = text(node.type)?.toLowerCase();
190
+ const testId = text(node.identifier);
191
+ if (!isActionableRole(role) && !testId) continue;
192
+ screenName ??= label;
193
+ const item = compactItem({
194
+ role,
195
+ label,
196
+ test_id: testId,
197
+ enabled: boolean(node.enabled),
198
+ selected: boolean(node.selected),
199
+ focused: boolean(node.focused),
200
+ bounds,
201
+ });
202
+ if (hasVisibleBounds(bounds)) {
203
+ visible.push(item);
204
+ } else {
205
+ hidden.push({ ...item, reason: 'hidden_or_offscreen' });
206
+ }
207
+ }
208
+ return {
209
+ visible,
210
+ hidden,
211
+ nodeCount: Array.isArray(nodes) ? nodes.length : 0,
212
+ screenName,
213
+ };
214
+ }
215
+
216
+ function isOpaqueRuntime(env) {
217
+ return env.METAMASK_RECIPE_MOBILE_OPAQUE_RUNTIME === '1';
218
+ }
219
+
138
220
  function normalizedHierarchy(
139
221
  provider,
140
222
  platform,
@@ -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
  }
@@ -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,