@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
|
@@ -0,0 +1,493 @@
|
|
|
1
|
+
import { createAgentDeviceUiTransport } from '@farmslot/expo-recipe';
|
|
2
|
+
import { createAgentDeviceClient } from 'agent-device';
|
|
3
|
+
|
|
4
|
+
const TEST_IDS = {
|
|
5
|
+
existingWallet: 'wallet-setup-screen-have-an-existing-wallet-button-id',
|
|
6
|
+
importSeed: 'onboarding-sheet-import-seed-button-id',
|
|
7
|
+
srp: 'phrase-input-id',
|
|
8
|
+
continue: 'import-from-seed-screen-continue-button-id',
|
|
9
|
+
password: 'create-password-first-input-field',
|
|
10
|
+
confirmPassword: 'create-password-second-input-field',
|
|
11
|
+
passwordTerms: 'password-understand-box',
|
|
12
|
+
createPassword: 'submit-button',
|
|
13
|
+
metricsCheckbox: 'optin-metrics-metrics-checkbox',
|
|
14
|
+
metricsContinue: 'optin-metrics-continue-button-id',
|
|
15
|
+
interestSkip: 'onboarding-interest-skip-button',
|
|
16
|
+
interestContinue: 'onboarding-interest-continue-button',
|
|
17
|
+
successDone: 'onboarding-success-done-button',
|
|
18
|
+
notificationYes: 'push-notification-new-user-sheet-button-yes',
|
|
19
|
+
notificationNotNow: 'push-notification-new-user-sheet-button-not-now',
|
|
20
|
+
walletTourSkip: 'wallet-home-onboarding-steps-skip',
|
|
21
|
+
wallet: 'wallet-screen',
|
|
22
|
+
walletSafeArea: 'wallet-safe-area',
|
|
23
|
+
walletHomepage: 'homepage-container',
|
|
24
|
+
walletScroll: 'wallet-scroll-view',
|
|
25
|
+
walletAddress: 'wallet-account-copy-button',
|
|
26
|
+
addressListBack: 'address-list-go-back',
|
|
27
|
+
walletTab: 'tab-bar-item-Wallet',
|
|
28
|
+
perpsHome: 'perps-home',
|
|
29
|
+
perpsHomeBack: 'perps-home-back-button',
|
|
30
|
+
perpsMarket: 'perps-market-details-view',
|
|
31
|
+
perpsMarketBack: 'perps-market-header-back-button',
|
|
32
|
+
hamburger: 'navbar-hamburger-menu-button',
|
|
33
|
+
lock: 'lock-menu-item',
|
|
34
|
+
confirmLock: 'android:id/button1',
|
|
35
|
+
login: 'login',
|
|
36
|
+
loginPassword: 'login-password-input',
|
|
37
|
+
loginButton: 'log-in-button',
|
|
38
|
+
forgotPassword: 'reset-wallet-button',
|
|
39
|
+
resetWallet: 'forgot-password-modal-reset-wallet-button',
|
|
40
|
+
confirmReset: 'forgot-password-modal-yes-reset-wallet-button',
|
|
41
|
+
};
|
|
42
|
+
|
|
43
|
+
const WALLET_SCREEN_IDS = [
|
|
44
|
+
TEST_IDS.wallet,
|
|
45
|
+
TEST_IDS.walletSafeArea,
|
|
46
|
+
TEST_IDS.walletHomepage,
|
|
47
|
+
TEST_IDS.walletScroll,
|
|
48
|
+
];
|
|
49
|
+
|
|
50
|
+
const POST_PASSWORD_TEST_IDS = [
|
|
51
|
+
TEST_IDS.metricsContinue,
|
|
52
|
+
TEST_IDS.interestSkip,
|
|
53
|
+
TEST_IDS.successDone,
|
|
54
|
+
TEST_IDS.notificationNotNow,
|
|
55
|
+
TEST_IDS.walletTourSkip,
|
|
56
|
+
...WALLET_SCREEN_IDS,
|
|
57
|
+
];
|
|
58
|
+
|
|
59
|
+
const BIOMETRIC_PROMPT_ID = 'com.android.systemui:id/biometric_prompt_constraint_layout';
|
|
60
|
+
const PERMISSION_CONTROLLER_PACKAGES = new Set([
|
|
61
|
+
'com.android.permissioncontroller',
|
|
62
|
+
'com.google.android.permissioncontroller',
|
|
63
|
+
]);
|
|
64
|
+
|
|
65
|
+
function isMetaMaskBiometricPrompt(nodes) {
|
|
66
|
+
const systemUiNodes = nodes.filter((node) => node.bundleId === 'com.android.systemui');
|
|
67
|
+
return systemUiNodes.some((node) => node.identifier === BIOMETRIC_PROMPT_ID) &&
|
|
68
|
+
systemUiNodes.some((node) =>
|
|
69
|
+
/metamask/iu.test(String(node.label ?? node.value ?? ''))
|
|
70
|
+
);
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
function isPostPasswordScreen(nodes) {
|
|
74
|
+
return nodes.some((node) => POST_PASSWORD_TEST_IDS.includes(String(node.identifier ?? '')));
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
function isMetaMaskNotificationPermissionPrompt(nodes) {
|
|
78
|
+
const permissionNodes = nodes.filter((node) => PERMISSION_CONTROLLER_PACKAGES.has(node.bundleId));
|
|
79
|
+
return permissionNodes.some((node) => /metamask/iu.test(String(node.label ?? node.value ?? ''))) &&
|
|
80
|
+
permissionNodes.some((node) => /notification/iu.test(String(node.label ?? node.value ?? node.identifier ?? '')));
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
function isWalletScreen(nodes) {
|
|
84
|
+
return nodes.some((node) => WALLET_SCREEN_IDS.includes(String(node.identifier ?? '')));
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
function hasBlockingSystemUi(nodes) {
|
|
88
|
+
return nodes.some((node) =>
|
|
89
|
+
(node.bundleId === 'com.android.systemui' || PERMISSION_CONTROLLER_PACKAGES.has(node.bundleId)) &&
|
|
90
|
+
/(?:dialog|prompt|permission)/iu.test(String(node.identifier ?? ''))
|
|
91
|
+
);
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
export function isOpaqueMobileRuntime(input) {
|
|
95
|
+
return input.context?.env?.METAMASK_RECIPE_MOBILE_OPAQUE_RUNTIME === '1' ||
|
|
96
|
+
process.env.METAMASK_RECIPE_MOBILE_OPAQUE_RUNTIME === '1';
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
export async function importWalletThroughNativeUi(input, credentials, options) {
|
|
100
|
+
const native = nativeSession(input);
|
|
101
|
+
try {
|
|
102
|
+
const initial = await native.normalizeWalletSurface(options.timeoutMs);
|
|
103
|
+
if (initial.wallet) {
|
|
104
|
+
if (options.requireFresh) {
|
|
105
|
+
throw new Error('metamask.wallet.import method=ui requires a fresh Mobile onboarding state.');
|
|
106
|
+
}
|
|
107
|
+
return await native.walletProof(credentials.expectedAddress, true, options.timeoutMs);
|
|
108
|
+
}
|
|
109
|
+
if (initial.visible.has(TEST_IDS.login)) {
|
|
110
|
+
if (options.requireFresh) {
|
|
111
|
+
throw new Error('metamask.wallet.import method=ui requires a fresh Mobile onboarding state.');
|
|
112
|
+
}
|
|
113
|
+
await native.fill(TEST_IDS.loginPassword, credentials.password, options.timeoutMs);
|
|
114
|
+
await native.press(TEST_IDS.loginButton, options.timeoutMs);
|
|
115
|
+
await native.waitForWallet(options.timeoutMs);
|
|
116
|
+
return await native.walletProof(credentials.expectedAddress, true, options.timeoutMs);
|
|
117
|
+
}
|
|
118
|
+
const resumedAfterPassword = initial.postPassword;
|
|
119
|
+
if (!resumedAfterPassword) {
|
|
120
|
+
await native.press(TEST_IDS.existingWallet, options.timeoutMs);
|
|
121
|
+
if (!await native.exists(TEST_IDS.srp, 3000)) {
|
|
122
|
+
await native.press(TEST_IDS.importSeed, options.timeoutMs);
|
|
123
|
+
}
|
|
124
|
+
await fillRecoveryPhrase(native, credentials.mnemonic, options.timeoutMs);
|
|
125
|
+
await native.press(TEST_IDS.continue, options.timeoutMs);
|
|
126
|
+
await native.fill(TEST_IDS.password, credentials.password, options.timeoutMs);
|
|
127
|
+
await native.fill(TEST_IDS.confirmPassword, credentials.password, options.timeoutMs);
|
|
128
|
+
await native.dismissKeyboard();
|
|
129
|
+
await native.press(TEST_IDS.passwordTerms, options.timeoutMs);
|
|
130
|
+
await native.press(TEST_IDS.createPassword, options.timeoutMs);
|
|
131
|
+
}
|
|
132
|
+
const onboarding = await native.completeOnboarding(options);
|
|
133
|
+
return {
|
|
134
|
+
...await native.walletProof(credentials.expectedAddress, false, options.timeoutMs),
|
|
135
|
+
...onboarding,
|
|
136
|
+
};
|
|
137
|
+
} finally {
|
|
138
|
+
await native.close();
|
|
139
|
+
}
|
|
140
|
+
}
|
|
141
|
+
|
|
142
|
+
async function fillRecoveryPhrase(native, mnemonic, timeoutMs) {
|
|
143
|
+
const words = mnemonic.trim().split(/\s+/u);
|
|
144
|
+
await native.fill(TEST_IDS.srp, words[0], timeoutMs);
|
|
145
|
+
for (const [index, word] of words.slice(1).entries()) {
|
|
146
|
+
await native.type(' ');
|
|
147
|
+
await native.wait(`${TEST_IDS.srp}_${index + 1}`, timeoutMs);
|
|
148
|
+
await native.type(word);
|
|
149
|
+
}
|
|
150
|
+
await native.verifyRecoveryPhrase(words);
|
|
151
|
+
await native.dismissKeyboard();
|
|
152
|
+
}
|
|
153
|
+
|
|
154
|
+
export async function readWalletThroughNativeUi(input) {
|
|
155
|
+
const native = nativeSession(input);
|
|
156
|
+
try {
|
|
157
|
+
const timeoutMs = Number(input.node?.timeout_ms ?? 30_000);
|
|
158
|
+
await native.normalizeWalletSurface(timeoutMs);
|
|
159
|
+
await native.waitForWallet(timeoutMs);
|
|
160
|
+
return await native.walletProof(undefined, true, timeoutMs);
|
|
161
|
+
} finally {
|
|
162
|
+
await native.close();
|
|
163
|
+
}
|
|
164
|
+
}
|
|
165
|
+
|
|
166
|
+
export async function resetWalletThroughNativeUi(input) {
|
|
167
|
+
const native = nativeSession(input);
|
|
168
|
+
const timeoutMs = Number(input.node?.timeout_ms ?? 60_000);
|
|
169
|
+
try {
|
|
170
|
+
if (await native.exists(TEST_IDS.existingWallet, 1000)) {
|
|
171
|
+
return { alreadyFresh: true, provider: 'android-native-accessibility' };
|
|
172
|
+
}
|
|
173
|
+
if (await native.exists(TEST_IDS.confirmLock, 1000)) {
|
|
174
|
+
await native.press(TEST_IDS.confirmLock, timeoutMs);
|
|
175
|
+
} else {
|
|
176
|
+
const state = await native.normalizeWalletSurface(timeoutMs);
|
|
177
|
+
if (state.wallet) {
|
|
178
|
+
await native.press(TEST_IDS.hamburger, timeoutMs);
|
|
179
|
+
await native.press(TEST_IDS.lock, timeoutMs);
|
|
180
|
+
if (await native.exists(TEST_IDS.confirmLock, 3000)) {
|
|
181
|
+
await native.press(TEST_IDS.confirmLock, timeoutMs);
|
|
182
|
+
}
|
|
183
|
+
}
|
|
184
|
+
}
|
|
185
|
+
await native.wait(TEST_IDS.login, timeoutMs);
|
|
186
|
+
await native.press(TEST_IDS.forgotPassword, timeoutMs);
|
|
187
|
+
await native.press(TEST_IDS.resetWallet, timeoutMs);
|
|
188
|
+
await native.press(TEST_IDS.confirmReset, timeoutMs);
|
|
189
|
+
await native.wait(TEST_IDS.existingWallet, timeoutMs);
|
|
190
|
+
return { alreadyFresh: false, provider: 'android-native-accessibility' };
|
|
191
|
+
} finally {
|
|
192
|
+
await native.close();
|
|
193
|
+
}
|
|
194
|
+
}
|
|
195
|
+
|
|
196
|
+
function nativeSession(input) {
|
|
197
|
+
const env = { ...process.env, ...(input.context?.env ?? {}) };
|
|
198
|
+
const device = String(env.ADB_SERIAL ?? env.ANDROID_SERIAL ?? '').trim();
|
|
199
|
+
if (!device) throw new Error('Opaque Mobile UI execution requires one explicit Android device serial.');
|
|
200
|
+
const app = String(env.ANDROID_PACKAGE_ID ?? 'io.metamask');
|
|
201
|
+
const session = `mm-harness-wallet-${process.pid}-${String(input.context?.nodeId ?? 'node')}`
|
|
202
|
+
.replace(/[^A-Za-z0-9._-]/gu, '-');
|
|
203
|
+
const client = createAgentDeviceClient({
|
|
204
|
+
session,
|
|
205
|
+
lockPolicy: 'reject',
|
|
206
|
+
lockPlatform: 'android',
|
|
207
|
+
});
|
|
208
|
+
const selection = { platform: 'android', target: 'mobile', serial: device, session };
|
|
209
|
+
const transport = createAgentDeviceUiTransport({
|
|
210
|
+
platform: 'android',
|
|
211
|
+
device,
|
|
212
|
+
app,
|
|
213
|
+
session,
|
|
214
|
+
client,
|
|
215
|
+
});
|
|
216
|
+
const context = {
|
|
217
|
+
nodeId: String(input.context?.nodeId ?? 'wallet'),
|
|
218
|
+
projectRoot: input.context.projectRoot,
|
|
219
|
+
artifactsDir: input.context.artifactsDir,
|
|
220
|
+
env,
|
|
221
|
+
};
|
|
222
|
+
const execute = (action, node) => transport.execute(action, node, context);
|
|
223
|
+
return {
|
|
224
|
+
press: (testId, timeoutMs) => execute('ui.press', { test_id: testId, timeout_ms: timeoutMs }),
|
|
225
|
+
fill: (testId, value, timeoutMs) => execute('ui.set_input', {
|
|
226
|
+
test_id: testId,
|
|
227
|
+
value,
|
|
228
|
+
timeout_ms: timeoutMs,
|
|
229
|
+
settle: false,
|
|
230
|
+
}),
|
|
231
|
+
type: (value) => client.interactions.type({
|
|
232
|
+
...selection,
|
|
233
|
+
text: value,
|
|
234
|
+
delayMs: 50,
|
|
235
|
+
responseLevel: 'digest',
|
|
236
|
+
}),
|
|
237
|
+
async verifyRecoveryPhrase(words) {
|
|
238
|
+
const snapshot = await client.capture.snapshot({
|
|
239
|
+
...selection,
|
|
240
|
+
interactiveOnly: false,
|
|
241
|
+
forceFull: true,
|
|
242
|
+
});
|
|
243
|
+
const actual = new Map(snapshot.nodes
|
|
244
|
+
.filter((node) => /^phrase-input-id_\d+$/u.test(String(node.identifier ?? '')))
|
|
245
|
+
.map((node) => [
|
|
246
|
+
Number(String(node.identifier).split('_').at(-1)),
|
|
247
|
+
String(node.value ?? node.label ?? ''),
|
|
248
|
+
]));
|
|
249
|
+
const mismatches = words.flatMap((word, index) =>
|
|
250
|
+
actual.get(index) === word ? [] : [index + 1]
|
|
251
|
+
);
|
|
252
|
+
if (actual.size !== words.length || mismatches.length > 0) {
|
|
253
|
+
throw new Error(
|
|
254
|
+
`Mobile recovery phrase entry did not match ${words.length} expected fields; mismatched field numbers: ${mismatches.join(', ') || 'unknown'}.`,
|
|
255
|
+
);
|
|
256
|
+
}
|
|
257
|
+
},
|
|
258
|
+
dismissKeyboard: () => client.command.keyboard({
|
|
259
|
+
...selection,
|
|
260
|
+
action: 'dismiss',
|
|
261
|
+
responseLevel: 'digest',
|
|
262
|
+
}),
|
|
263
|
+
async isPostPasswordScreen() {
|
|
264
|
+
return (await this.screenState()).postPassword;
|
|
265
|
+
},
|
|
266
|
+
async screenState() {
|
|
267
|
+
const snapshot = await client.capture.snapshot({
|
|
268
|
+
...selection,
|
|
269
|
+
interactiveOnly: false,
|
|
270
|
+
forceFull: true,
|
|
271
|
+
});
|
|
272
|
+
return {
|
|
273
|
+
nodes: snapshot.nodes,
|
|
274
|
+
visible: new Set(snapshot.nodes.map((node) => String(node.identifier ?? ''))),
|
|
275
|
+
wallet: isWalletScreen(snapshot.nodes),
|
|
276
|
+
postPassword: isPostPasswordScreen(snapshot.nodes) || isMetaMaskBiometricPrompt(snapshot.nodes),
|
|
277
|
+
};
|
|
278
|
+
},
|
|
279
|
+
async normalizeWalletSurface(timeoutMs) {
|
|
280
|
+
const deadline = Date.now() + timeoutMs;
|
|
281
|
+
for (let attempt = 0; attempt < 5 && Date.now() <= deadline; attempt += 1) {
|
|
282
|
+
const state = await this.screenState();
|
|
283
|
+
if (
|
|
284
|
+
state.wallet ||
|
|
285
|
+
state.postPassword ||
|
|
286
|
+
state.visible.has(TEST_IDS.existingWallet) ||
|
|
287
|
+
state.visible.has(TEST_IDS.login)
|
|
288
|
+
) {
|
|
289
|
+
return state;
|
|
290
|
+
}
|
|
291
|
+
if (state.visible.has(TEST_IDS.perpsMarketBack)) {
|
|
292
|
+
await this.press(TEST_IDS.perpsMarketBack, timeoutMs);
|
|
293
|
+
} else if (state.visible.has(TEST_IDS.perpsHomeBack)) {
|
|
294
|
+
await this.press(TEST_IDS.perpsHomeBack, timeoutMs);
|
|
295
|
+
} else if (state.visible.has(TEST_IDS.walletTab)) {
|
|
296
|
+
await this.press(TEST_IDS.walletTab, timeoutMs);
|
|
297
|
+
} else if (state.visible.has(TEST_IDS.addressListBack)) {
|
|
298
|
+
await this.press(TEST_IDS.addressListBack, timeoutMs);
|
|
299
|
+
} else {
|
|
300
|
+
await client.command.back({
|
|
301
|
+
...selection,
|
|
302
|
+
mode: 'system',
|
|
303
|
+
responseLevel: 'digest',
|
|
304
|
+
});
|
|
305
|
+
}
|
|
306
|
+
}
|
|
307
|
+
throw new Error('Mobile wallet action could not reach wallet home, login, or onboarding.');
|
|
308
|
+
},
|
|
309
|
+
async completeOnboarding(options) {
|
|
310
|
+
const deadline = Date.now() + options.timeoutMs;
|
|
311
|
+
let biometricPromptDismissed = false;
|
|
312
|
+
let notificationPromptHandled = false;
|
|
313
|
+
let notificationsEnabled = false;
|
|
314
|
+
let walletTourSkipped = false;
|
|
315
|
+
let interestsHandled = false;
|
|
316
|
+
let awaitingNotificationPermission = false;
|
|
317
|
+
while (Date.now() <= deadline) {
|
|
318
|
+
const snapshot = await client.capture.snapshot({
|
|
319
|
+
...selection,
|
|
320
|
+
interactiveOnly: false,
|
|
321
|
+
forceFull: true,
|
|
322
|
+
});
|
|
323
|
+
if (isMetaMaskBiometricPrompt(snapshot.nodes)) {
|
|
324
|
+
if (options.biometricsEnabled) {
|
|
325
|
+
throw new Error('Mobile biometrics=true requires manual device authentication and is not supported by an unattended recipe.');
|
|
326
|
+
}
|
|
327
|
+
await client.command.back({
|
|
328
|
+
...selection,
|
|
329
|
+
mode: 'system',
|
|
330
|
+
responseLevel: 'digest',
|
|
331
|
+
});
|
|
332
|
+
biometricPromptDismissed = true;
|
|
333
|
+
continue;
|
|
334
|
+
}
|
|
335
|
+
if (isMetaMaskNotificationPermissionPrompt(snapshot.nodes) && awaitingNotificationPermission) {
|
|
336
|
+
await client.command.alert({
|
|
337
|
+
...selection,
|
|
338
|
+
action: 'accept',
|
|
339
|
+
timeoutMs: 5000,
|
|
340
|
+
responseLevel: 'digest',
|
|
341
|
+
});
|
|
342
|
+
awaitingNotificationPermission = false;
|
|
343
|
+
notificationsEnabled = true;
|
|
344
|
+
continue;
|
|
345
|
+
}
|
|
346
|
+
if (hasBlockingSystemUi(snapshot.nodes)) {
|
|
347
|
+
throw new Error('Unexpected Android system UI blocked Mobile wallet creation; refusing to dismiss it as biometric authentication.');
|
|
348
|
+
}
|
|
349
|
+
const visible = new Set(snapshot.nodes.map((node) => String(node.identifier ?? '')));
|
|
350
|
+
if (visible.has(TEST_IDS.metricsContinue)) {
|
|
351
|
+
if (!options.metametricsEnabled) {
|
|
352
|
+
await this.press(TEST_IDS.metricsCheckbox, options.timeoutMs);
|
|
353
|
+
}
|
|
354
|
+
await this.press(TEST_IDS.metricsContinue, options.timeoutMs);
|
|
355
|
+
continue;
|
|
356
|
+
}
|
|
357
|
+
if (visible.has(TEST_IDS.interestSkip)) {
|
|
358
|
+
interestsHandled = true;
|
|
359
|
+
if (options.interests.length > 0) {
|
|
360
|
+
for (const interest of options.interests) {
|
|
361
|
+
await this.press(`onboarding-interest-option-${interest}`, options.timeoutMs);
|
|
362
|
+
}
|
|
363
|
+
await this.press(TEST_IDS.interestContinue, options.timeoutMs);
|
|
364
|
+
} else {
|
|
365
|
+
await this.press(TEST_IDS.interestSkip, options.timeoutMs);
|
|
366
|
+
}
|
|
367
|
+
continue;
|
|
368
|
+
}
|
|
369
|
+
if (visible.has(TEST_IDS.successDone)) {
|
|
370
|
+
if (options.interests.length > 0 && !interestsHandled) {
|
|
371
|
+
throw new Error('Mobile onboarding interests were requested, but the questionnaire is not enabled for this build.');
|
|
372
|
+
}
|
|
373
|
+
await this.press(TEST_IDS.successDone, options.timeoutMs);
|
|
374
|
+
continue;
|
|
375
|
+
}
|
|
376
|
+
if (visible.has(TEST_IDS.notificationNotNow)) {
|
|
377
|
+
await this.press(
|
|
378
|
+
options.notificationsEnabled ? TEST_IDS.notificationYes : TEST_IDS.notificationNotNow,
|
|
379
|
+
options.timeoutMs,
|
|
380
|
+
);
|
|
381
|
+
notificationPromptHandled = true;
|
|
382
|
+
awaitingNotificationPermission = options.notificationsEnabled;
|
|
383
|
+
continue;
|
|
384
|
+
}
|
|
385
|
+
if (visible.has(TEST_IDS.walletTourSkip)) {
|
|
386
|
+
if (options.skipWalletTour) {
|
|
387
|
+
await this.press(TEST_IDS.walletTourSkip, options.timeoutMs);
|
|
388
|
+
walletTourSkipped = true;
|
|
389
|
+
continue;
|
|
390
|
+
}
|
|
391
|
+
throw new Error('Mobile wallet-home tour remains visible because skip_wallet_tour=false; wallet readiness cannot be proven.');
|
|
392
|
+
}
|
|
393
|
+
if (WALLET_SCREEN_IDS.some((testId) => visible.has(testId))) {
|
|
394
|
+
if (options.interests.length > 0 && !interestsHandled) {
|
|
395
|
+
throw new Error('Mobile onboarding interests were requested, but the questionnaire is not enabled for this build.');
|
|
396
|
+
}
|
|
397
|
+
return {
|
|
398
|
+
biometricsEnabled: options.biometricsEnabled,
|
|
399
|
+
biometricPromptDismissed,
|
|
400
|
+
notificationsEnabled,
|
|
401
|
+
notificationPromptHandled,
|
|
402
|
+
walletTourSkipped,
|
|
403
|
+
};
|
|
404
|
+
}
|
|
405
|
+
await new Promise((resolve) => setTimeout(resolve, 250));
|
|
406
|
+
}
|
|
407
|
+
throw new Error('Mobile onboarding did not reach a usable wallet before the import timeout.');
|
|
408
|
+
},
|
|
409
|
+
wait: (testId, timeoutMs) => execute('ui.wait_for', {
|
|
410
|
+
test_id: testId,
|
|
411
|
+
expected: 'visible',
|
|
412
|
+
timeout_ms: timeoutMs,
|
|
413
|
+
settle: false,
|
|
414
|
+
}),
|
|
415
|
+
async waitForWallet(timeoutMs) {
|
|
416
|
+
const deadline = Date.now() + timeoutMs;
|
|
417
|
+
while (Date.now() <= deadline) {
|
|
418
|
+
if ((await this.screenState()).wallet) return;
|
|
419
|
+
await new Promise((resolve) => setTimeout(resolve, 250));
|
|
420
|
+
}
|
|
421
|
+
throw new Error('Mobile wallet home did not become visible before the readiness timeout.');
|
|
422
|
+
},
|
|
423
|
+
async exists(testId, timeoutMs) {
|
|
424
|
+
try {
|
|
425
|
+
await execute('ui.wait_for', {
|
|
426
|
+
test_id: testId,
|
|
427
|
+
expected: 'visible',
|
|
428
|
+
timeout_ms: timeoutMs,
|
|
429
|
+
settle: false,
|
|
430
|
+
});
|
|
431
|
+
return true;
|
|
432
|
+
} catch {
|
|
433
|
+
return false;
|
|
434
|
+
}
|
|
435
|
+
},
|
|
436
|
+
async walletProof(expectedAddress, alreadyReady, timeoutMs) {
|
|
437
|
+
const observed = await transport.observe?.(['ui.screen', 'ui.visible']);
|
|
438
|
+
let rawText = JSON.stringify(observed ?? '').toLowerCase();
|
|
439
|
+
const text = rawText.replace(/[^a-z0-9]/gu, '');
|
|
440
|
+
const address = expectedAddress?.toLowerCase().replace(/[^a-z0-9]/gu, '');
|
|
441
|
+
let addressVisible = address
|
|
442
|
+
? text.includes(address) || (text.includes(address.slice(0, 7)) && text.includes(address.slice(-5)))
|
|
443
|
+
: /0x[a-f0-9]{5,40}(?:…|\.\.\.)?[a-f0-9]{0,8}/u.test(rawText);
|
|
444
|
+
const deadline = Date.now() + Math.min(timeoutMs, 30_000);
|
|
445
|
+
while (!addressVisible && Date.now() <= deadline) {
|
|
446
|
+
let openedAddressList = false;
|
|
447
|
+
try {
|
|
448
|
+
await this.press(TEST_IDS.walletAddress, 15_000);
|
|
449
|
+
openedAddressList = true;
|
|
450
|
+
const addressState = await this.screenState();
|
|
451
|
+
rawText = JSON.stringify(addressState.nodes.map((node) => [node.label, node.value])).toLowerCase();
|
|
452
|
+
const normalized = rawText.replace(/[^a-z0-9]/gu, '');
|
|
453
|
+
addressVisible = address
|
|
454
|
+
? normalized.includes(address) ||
|
|
455
|
+
(normalized.includes(address.slice(0, 7)) && normalized.includes(address.slice(-5)))
|
|
456
|
+
: /0x[a-f0-9]{5,40}(?:…|\.\.\.)?[a-f0-9]{0,8}/u.test(rawText);
|
|
457
|
+
} finally {
|
|
458
|
+
if (openedAddressList) {
|
|
459
|
+
if (await this.exists(TEST_IDS.addressListBack, 2000)) {
|
|
460
|
+
await this.press(TEST_IDS.addressListBack, 15_000);
|
|
461
|
+
} else {
|
|
462
|
+
await client.command.back({
|
|
463
|
+
...selection,
|
|
464
|
+
mode: 'system',
|
|
465
|
+
responseLevel: 'digest',
|
|
466
|
+
});
|
|
467
|
+
}
|
|
468
|
+
}
|
|
469
|
+
}
|
|
470
|
+
if (!addressVisible) await new Promise((resolve) => setTimeout(resolve, 250));
|
|
471
|
+
}
|
|
472
|
+
const observedAddress = rawText.match(/0x[a-f0-9]{5,40}(?:…|\.\.\.)?[a-f0-9]{0,8}/u)?.[0] ?? null;
|
|
473
|
+
return {
|
|
474
|
+
alreadyReady,
|
|
475
|
+
selectedAddress: addressVisible && expectedAddress ? expectedAddress : null,
|
|
476
|
+
selectedAddressObserved: observedAddress,
|
|
477
|
+
...(expectedAddress ? { selectedAddressExpected: expectedAddress } : {}),
|
|
478
|
+
addressVisible,
|
|
479
|
+
walletVisible: true,
|
|
480
|
+
provider: 'android-native-accessibility',
|
|
481
|
+
redacted: true,
|
|
482
|
+
proofPath: 'mobile-visible-onboarding-import-native',
|
|
483
|
+
};
|
|
484
|
+
},
|
|
485
|
+
async close() {
|
|
486
|
+
try {
|
|
487
|
+
await client.sessions.close({ session, shutdown: false });
|
|
488
|
+
} catch (error) {
|
|
489
|
+
if (!/No active session|SESSION_NOT_FOUND/iu.test(String(error?.message ?? error))) throw error;
|
|
490
|
+
}
|
|
491
|
+
},
|
|
492
|
+
};
|
|
493
|
+
}
|
|
@@ -1,9 +1,25 @@
|
|
|
1
1
|
import { bridgeCommand, runAdapter, selectBridgeStatusEntry } from '../platform/bridge.mjs';
|
|
2
|
+
import { isOpaqueMobileRuntime, readWalletThroughNativeUi } from './native-ui.mjs';
|
|
2
3
|
|
|
3
4
|
// selectedStatus: shared pin-aware entry selection (see selectBridgeStatusEntry).
|
|
4
5
|
const selectedStatus = selectBridgeStatusEntry;
|
|
5
6
|
|
|
6
7
|
runAdapter(async (input) => {
|
|
8
|
+
if (isOpaqueMobileRuntime(input)) {
|
|
9
|
+
const proof = await readWalletThroughNativeUi(input);
|
|
10
|
+
if (!proof.addressVisible) {
|
|
11
|
+
throw new Error('Mobile wallet home is visible, but the selected account address is not observable through native accessibility.');
|
|
12
|
+
}
|
|
13
|
+
return {
|
|
14
|
+
action: input.action,
|
|
15
|
+
source: proof.provider,
|
|
16
|
+
account: { address: proof.selectedAddress, displayAddress: proof.selectedAddressObserved },
|
|
17
|
+
route: { name: 'WalletView', observedBy: 'wallet-screen' },
|
|
18
|
+
platform: 'android',
|
|
19
|
+
redacted: true,
|
|
20
|
+
proofPath: 'native-wallet-status',
|
|
21
|
+
};
|
|
22
|
+
}
|
|
7
23
|
const status = selectedStatus(await bridgeCommand(input, ['status']), input);
|
|
8
24
|
const output = {
|
|
9
25
|
action: input.action,
|
|
@@ -1,7 +1,20 @@
|
|
|
1
1
|
import { runAdapter } from '../platform/bridge.mjs';
|
|
2
2
|
import { resetMobileWalletThroughUi } from './reset-helper.mjs';
|
|
3
|
+
import { isOpaqueMobileRuntime, resetWalletThroughNativeUi } from './native-ui.mjs';
|
|
3
4
|
|
|
4
|
-
runAdapter(async (input) =>
|
|
5
|
-
|
|
6
|
-
|
|
7
|
-
|
|
5
|
+
runAdapter(async (input) => {
|
|
6
|
+
if (isOpaqueMobileRuntime(input)) {
|
|
7
|
+
const result = await resetWalletThroughNativeUi(input);
|
|
8
|
+
return {
|
|
9
|
+
action: input.action,
|
|
10
|
+
...result,
|
|
11
|
+
route: 'Onboarding',
|
|
12
|
+
redacted: true,
|
|
13
|
+
proofPath: 'mobile-visible-wallet-reset-native',
|
|
14
|
+
};
|
|
15
|
+
}
|
|
16
|
+
return {
|
|
17
|
+
action: input.action,
|
|
18
|
+
...(await resetMobileWalletThroughUi(input)),
|
|
19
|
+
};
|
|
20
|
+
});
|
|
@@ -30,6 +30,7 @@ export function locatorsFromObservation(observation) {
|
|
|
30
30
|
const leftBest = left.suggestions[0];
|
|
31
31
|
const rightBest = right.suggestions[0];
|
|
32
32
|
return (
|
|
33
|
+
locatorPriority(left) - locatorPriority(right) ||
|
|
33
34
|
confidenceRank(leftBest?.confidence) - confidenceRank(rightBest?.confidence) ||
|
|
34
35
|
strategyRank(leftBest?.strategy) - strategyRank(rightBest?.strategy)
|
|
35
36
|
);
|
|
@@ -44,6 +45,12 @@ export function locatorsFromObservation(observation) {
|
|
|
44
45
|
};
|
|
45
46
|
}
|
|
46
47
|
|
|
48
|
+
function locatorPriority(locator) {
|
|
49
|
+
const role = text(locator.role)?.toLowerCase() ?? '';
|
|
50
|
+
if (INTERACTIVE_ROLE_PARTS.some((part) => role.includes(part))) return 0;
|
|
51
|
+
return locator.description.includes('label=') ? 1 : 2;
|
|
52
|
+
}
|
|
53
|
+
|
|
47
54
|
export function requireVisibleObservation(result) {
|
|
48
55
|
const value = record(result);
|
|
49
56
|
const observations = record(value.observations);
|
|
@@ -1180,6 +1180,21 @@
|
|
|
1180
1180
|
"default": false,
|
|
1181
1181
|
"description": "Enable MetaMetrics during visible onboarding."
|
|
1182
1182
|
},
|
|
1183
|
+
"biometrics": {
|
|
1184
|
+
"type": "boolean",
|
|
1185
|
+
"default": false,
|
|
1186
|
+
"description": "Mobile-only biometric onboarding preference; ignored on Extension."
|
|
1187
|
+
},
|
|
1188
|
+
"notifications": {
|
|
1189
|
+
"type": "boolean",
|
|
1190
|
+
"default": false,
|
|
1191
|
+
"description": "Mobile-only push notification onboarding preference; ignored on Extension."
|
|
1192
|
+
},
|
|
1193
|
+
"skip_wallet_tour": {
|
|
1194
|
+
"type": "boolean",
|
|
1195
|
+
"default": true,
|
|
1196
|
+
"description": "Mobile-only wallet-home tour preference; ignored on Extension."
|
|
1197
|
+
},
|
|
1183
1198
|
"interests": {
|
|
1184
1199
|
"type": "array",
|
|
1185
1200
|
"items": {
|
|
@@ -1433,6 +1448,107 @@
|
|
|
1433
1448
|
],
|
|
1434
1449
|
"execution_capabilities": []
|
|
1435
1450
|
},
|
|
1451
|
+
"metamask.perps.read_snapshot": {
|
|
1452
|
+
"description": "Read a bounded, redacted snapshot of live Extension Perps state through product background APIs.",
|
|
1453
|
+
"schema": {
|
|
1454
|
+
"type": "object",
|
|
1455
|
+
"properties": {
|
|
1456
|
+
"include": {
|
|
1457
|
+
"type": "array",
|
|
1458
|
+
"items": {
|
|
1459
|
+
"type": "string",
|
|
1460
|
+
"enum": [
|
|
1461
|
+
"account",
|
|
1462
|
+
"markets",
|
|
1463
|
+
"positions",
|
|
1464
|
+
"orders",
|
|
1465
|
+
"history",
|
|
1466
|
+
"watchlist"
|
|
1467
|
+
]
|
|
1468
|
+
},
|
|
1469
|
+
"minItems": 1,
|
|
1470
|
+
"default": [
|
|
1471
|
+
"account",
|
|
1472
|
+
"markets",
|
|
1473
|
+
"positions",
|
|
1474
|
+
"orders",
|
|
1475
|
+
"history",
|
|
1476
|
+
"watchlist"
|
|
1477
|
+
],
|
|
1478
|
+
"description": "Sections to request. Every requested section must be available or the action fails."
|
|
1479
|
+
},
|
|
1480
|
+
"market": {
|
|
1481
|
+
"type": "string",
|
|
1482
|
+
"description": "Optional single market filter."
|
|
1483
|
+
},
|
|
1484
|
+
"markets": {
|
|
1485
|
+
"type": "array",
|
|
1486
|
+
"items": {
|
|
1487
|
+
"type": "string"
|
|
1488
|
+
},
|
|
1489
|
+
"maxItems": 100,
|
|
1490
|
+
"description": "Optional market filters applied to market-scoped sections."
|
|
1491
|
+
},
|
|
1492
|
+
"history": {
|
|
1493
|
+
"type": "object",
|
|
1494
|
+
"properties": {
|
|
1495
|
+
"start_time_ms": {
|
|
1496
|
+
"type": "integer",
|
|
1497
|
+
"minimum": 0,
|
|
1498
|
+
"description": "Inclusive history start time in Unix milliseconds. Defaults to 30 days before end_time_ms."
|
|
1499
|
+
},
|
|
1500
|
+
"end_time_ms": {
|
|
1501
|
+
"type": "integer",
|
|
1502
|
+
"minimum": 0,
|
|
1503
|
+
"description": "Inclusive history end time in Unix milliseconds. Defaults to the current time."
|
|
1504
|
+
},
|
|
1505
|
+
"account_id": {
|
|
1506
|
+
"type": "string",
|
|
1507
|
+
"description": "Optional CAIP account ID passed to the product history API; the value is not returned."
|
|
1508
|
+
},
|
|
1509
|
+
"limit": {
|
|
1510
|
+
"type": "integer",
|
|
1511
|
+
"minimum": 1,
|
|
1512
|
+
"default": 100,
|
|
1513
|
+
"description": "Maximum history items returned after product filtering."
|
|
1514
|
+
}
|
|
1515
|
+
},
|
|
1516
|
+
"additionalProperties": false
|
|
1517
|
+
},
|
|
1518
|
+
"max_items_per_section": {
|
|
1519
|
+
"type": "integer",
|
|
1520
|
+
"minimum": 1,
|
|
1521
|
+
"default": 100,
|
|
1522
|
+
"description": "Maximum normalized items returned for each requested list section."
|
|
1523
|
+
},
|
|
1524
|
+
"timeout_ms": {
|
|
1525
|
+
"type": "integer",
|
|
1526
|
+
"minimum": 1000,
|
|
1527
|
+
"default": 20000,
|
|
1528
|
+
"description": "Per-request background API timeout."
|
|
1529
|
+
}
|
|
1530
|
+
},
|
|
1531
|
+
"additionalProperties": false
|
|
1532
|
+
},
|
|
1533
|
+
"examples": [
|
|
1534
|
+
{
|
|
1535
|
+
"action": "metamask.perps.read_snapshot",
|
|
1536
|
+
"include": [
|
|
1537
|
+
"account",
|
|
1538
|
+
"markets",
|
|
1539
|
+
"positions",
|
|
1540
|
+
"orders"
|
|
1541
|
+
],
|
|
1542
|
+
"markets": [
|
|
1543
|
+
"BTC",
|
|
1544
|
+
"ETH"
|
|
1545
|
+
],
|
|
1546
|
+
"intent": "Read the bounded Perps state needed for release validation",
|
|
1547
|
+
"next": "done"
|
|
1548
|
+
}
|
|
1549
|
+
],
|
|
1550
|
+
"execution_capabilities": []
|
|
1551
|
+
},
|
|
1436
1552
|
"metamask.perps.read_positions": {
|
|
1437
1553
|
"description": "extension Read live Perps positions; without a selector, return all live positions.",
|
|
1438
1554
|
"schema": {
|