@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.
- package/CHANGELOG.md +24 -0
- package/adapters/extension/artifact-runtime-state.cjs +128 -0
- package/adapters/extension/inject.mjs +1 -0
- package/adapters/extension/launch-browser.cjs +22 -0
- package/adapters/extension/live.sh +82 -12
- package/adapters/extension/readiness.mjs +77 -36
- package/adapters/extension/snapshot-dist.sh +88 -3
- package/adapters/extension/verify.sh +6 -2
- package/adapters/manifest.json +9 -1
- package/adapters/shared/log-tui.mjs +1 -1
- package/dist/adapters/extension/artifact-integrity.js +38 -0
- package/dist/adapters/extension/extension-id.js +23 -4
- package/dist/adapters/extension/release-artifact.js +386 -0
- package/dist/adapters/extension/runtime-decision.js +161 -20
- package/dist/adapters/extension/runtime.js +127 -0
- package/dist/adapters/mobile/release-artifact-state.js +124 -0
- package/dist/adapters/mobile/release-artifact.js +295 -0
- package/dist/adapters.js +11 -4
- package/dist/command-contract.js +16 -0
- package/dist/commands/call.js +2 -1
- package/dist/commands/launch/extension.js +55 -6
- package/dist/commands/launch/mobile.js +2 -0
- package/dist/commands/provision.js +2 -0
- package/dist/commands/run-engine.js +89 -5
- package/dist/commands/run.js +3 -1
- package/dist/commands/runtime-launch.js +178 -10
- package/dist/heal-bounds.js +1 -1
- package/dist/live-adapter-contract.js +3 -1
- package/dist/metamask-action-validation.js +47 -1
- package/dist/mm-harness-cli.js +31 -1
- package/dist/run-diagnostics.js +1 -1
- package/docs/RELEASE-QA-CAPABILITY-MAP.md +150 -0
- package/library/actions/extension/perps/perps.mjs +2 -0
- package/library/actions/extension/perps/read_snapshot.mjs +470 -0
- package/library/actions/extension/platform/cdp.mjs +6 -3
- package/library/actions/extension/wallet/import.mjs +13 -46
- package/library/actions/extension/wallet/secret-input.mjs +98 -0
- package/library/actions/mobile/platform/observe-ui.mjs +84 -2
- package/library/actions/mobile/ui/native-navigation.mjs +225 -0
- package/library/actions/mobile/ui/navigate.mjs +7 -0
- package/library/actions/mobile/wallet/import.mjs +71 -2
- package/library/actions/mobile/wallet/native-ui.mjs +493 -0
- package/library/actions/mobile/wallet/read_state.mjs +16 -0
- package/library/actions/mobile/wallet/reset.mjs +17 -4
- package/library/actions/shared/ui/locators.mjs +7 -0
- package/library/manifests/extension.action-manifest.json +116 -0
- package/library/manifests/mobile.action-manifest.json +16 -0
- package/library/recipes/extension/runner/action-validation.recipe.json +12 -1
- package/library/recipes/wallet/import.recipe.json +20 -1
- package/library/recipes/wallet/reset-import.recipe.json +20 -1
- 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
|
|
124
|
-
|
|
125
|
-
|
|
126
|
-
|
|
127
|
-
|
|
128
|
-
|
|
129
|
-
|
|
130
|
-
|
|
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
|
|
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 =
|
|
9
|
-
const HIDDEN_LIMIT =
|
|
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,
|