@deeeed/metamask-harness 0.35.0 → 0.37.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (73) hide show
  1. package/CHANGELOG.md +39 -0
  2. package/README.md +9 -1
  3. package/adapters/extension/artifact-runtime-state.cjs +128 -0
  4. package/adapters/extension/check-infura-readiness.cjs +102 -0
  5. package/adapters/extension/inject.mjs +2 -0
  6. package/adapters/extension/launch-browser.cjs +22 -0
  7. package/adapters/extension/live.sh +103 -18
  8. package/adapters/extension/readiness.mjs +77 -36
  9. package/adapters/extension/snapshot-dist.sh +88 -3
  10. package/adapters/extension/start-watch.sh +15 -0
  11. package/adapters/extension/verify.sh +6 -2
  12. package/adapters/extension/wallet-fixture-state.cjs +3 -1
  13. package/adapters/manifest.json +25 -1
  14. package/adapters/mobile/bridge-runtime/cdp-bridge.cjs +73 -42
  15. package/adapters/mobile/reset-app-data.sh +154 -0
  16. package/adapters/shared/log-tui.mjs +1 -1
  17. package/dist/adapters/extension/artifact-integrity.js +38 -0
  18. package/dist/adapters/extension/extension-id.js +23 -4
  19. package/dist/adapters/extension/product-config.js +29 -1
  20. package/dist/adapters/extension/release-artifact.js +386 -0
  21. package/dist/adapters/extension/runtime-decision.js +161 -20
  22. package/dist/adapters/extension/runtime.js +127 -0
  23. package/dist/adapters/mobile/release-artifact-state.js +124 -0
  24. package/dist/adapters/mobile/release-artifact.js +295 -0
  25. package/dist/adapters.js +29 -4
  26. package/dist/cli-commands.js +1 -1
  27. package/dist/cli.js +2 -2
  28. package/dist/command-contract.js +17 -1
  29. package/dist/commands/call.js +2 -1
  30. package/dist/commands/device-target.js +5 -0
  31. package/dist/commands/fixtures.js +106 -31
  32. package/dist/commands/launch/extension.js +92 -7
  33. package/dist/commands/launch/index.js +13 -0
  34. package/dist/commands/launch/mobile.js +2 -0
  35. package/dist/commands/provision.js +2 -0
  36. package/dist/commands/run-engine.js +89 -5
  37. package/dist/commands/run.js +3 -1
  38. package/dist/commands/runtime-launch.js +178 -10
  39. package/dist/heal-bounds.js +1 -1
  40. package/dist/live-adapter-contract.js +3 -1
  41. package/dist/metamask-action-validation.js +47 -1
  42. package/dist/mm-harness-cli.js +37 -4
  43. package/dist/recipe-security.js +3 -0
  44. package/dist/run-diagnostics.js +1 -1
  45. package/docs/RECIPES.md +29 -0
  46. package/docs/RELEASE-QA-CAPABILITY-MAP.md +150 -0
  47. package/library/actions/extension/perps/perps.mjs +2 -0
  48. package/library/actions/extension/perps/read_snapshot.mjs +470 -0
  49. package/library/actions/extension/platform/cdp.mjs +6 -3
  50. package/library/actions/extension/wallet/import.mjs +201 -0
  51. package/library/actions/extension/wallet/reset.mjs +98 -0
  52. package/library/actions/extension/wallet/secret-input.mjs +98 -0
  53. package/library/actions/extension/wallet/state.mjs +1 -0
  54. package/library/actions/mobile/analytics/consent-settings.mjs +112 -0
  55. package/library/actions/mobile/analytics/set_consent.mjs +4 -112
  56. package/library/actions/mobile/platform/bridge.mjs +8 -0
  57. package/library/actions/mobile/platform/observe-ui.mjs +84 -2
  58. package/library/actions/mobile/ui/native-navigation.mjs +225 -0
  59. package/library/actions/mobile/ui/navigate.mjs +7 -0
  60. package/library/actions/mobile/wallet/import.mjs +328 -0
  61. package/library/actions/mobile/wallet/native-ui.mjs +493 -0
  62. package/library/actions/mobile/wallet/read_state.mjs +16 -0
  63. package/library/actions/mobile/wallet/reset-helper.mjs +99 -0
  64. package/library/actions/mobile/wallet/reset.mjs +20 -0
  65. package/library/actions/shared/ui/locators.mjs +7 -0
  66. package/library/actions/shared/wallet/import-source.mjs +101 -0
  67. package/library/manifests/extension.action-manifest.json +228 -0
  68. package/library/manifests/mobile.action-manifest.json +124 -0
  69. package/library/recipes/extension/runner/action-validation.recipe.json +12 -1
  70. package/library/recipes/wallet/import.recipe.json +102 -0
  71. package/library/recipes/wallet/reset-import.recipe.json +107 -0
  72. package/package.json +1 -1
  73. package/scripts/completions.sh +2 -2
@@ -0,0 +1,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,
@@ -0,0 +1,99 @@
1
+ import {
2
+ bridgeCommand,
3
+ selectBridgeStatusEntry,
4
+ } from '../platform/bridge.mjs';
5
+
6
+ const TEST_IDS = {
7
+ existingWallet: 'wallet-setup-screen-have-an-existing-wallet-button-id',
8
+ forgotPassword: 'reset-wallet-button',
9
+ resetWallet: 'forgot-password-modal-reset-wallet-button',
10
+ confirmReset: 'forgot-password-modal-yes-reset-wallet-button',
11
+ };
12
+
13
+ function selectedEntry(status, input) {
14
+ return selectBridgeStatusEntry(status, input) ?? {};
15
+ }
16
+
17
+ function walletState(status, input) {
18
+ const entry = selectedEntry(status, input);
19
+ return {
20
+ address: String(entry?.account?.address ?? '').toLowerCase(),
21
+ route: String(entry?.route?.name ?? ''),
22
+ };
23
+ }
24
+
25
+ async function isFreshWallet(status, input) {
26
+ const state = walletState(status, input);
27
+ if (state.route !== 'Onboarding') return false;
28
+ return bridgeCommand(input, ['eval', `(() => {
29
+ const hook = globalThis.__REACT_DEVTOOLS_GLOBAL_HOOK__;
30
+ const rootsFor = hook?.getFiberRoots;
31
+ if (!hook?.renderers || typeof rootsFor !== 'function') return false;
32
+ const walk = (fiber) => {
33
+ if (!fiber) return false;
34
+ if (fiber.memoizedProps?.testID === ${JSON.stringify(TEST_IDS.existingWallet)}) return true;
35
+ return walk(fiber.child) || walk(fiber.sibling);
36
+ };
37
+ for (const [id] of hook.renderers) {
38
+ for (const root of rootsFor(id) ?? []) {
39
+ if (walk(root.current)) return true;
40
+ }
41
+ }
42
+ return false;
43
+ })()`]);
44
+ }
45
+
46
+ async function waitForCommand(input, args, timeoutMs, label) {
47
+ const deadline = Date.now() + timeoutMs;
48
+ let lastError;
49
+ while (Date.now() <= deadline) {
50
+ try {
51
+ const result = await bridgeCommand(input, args);
52
+ if (result?.ok !== false) return result;
53
+ lastError = result?.error;
54
+ } catch (error) {
55
+ lastError = error;
56
+ }
57
+ await new Promise((resolve) => setTimeout(resolve, 250));
58
+ }
59
+ throw new Error(`${label} was not available before the wallet reset timeout: ${String(lastError?.message ?? lastError ?? 'unknown')}`);
60
+ }
61
+
62
+ async function waitForFreshWallet(input, timeoutMs) {
63
+ const deadline = Date.now() + timeoutMs;
64
+ let observed;
65
+ while (Date.now() <= deadline) {
66
+ observed = await bridgeCommand(input, ['status-selected']);
67
+ if (await isFreshWallet(observed, input)) return observed;
68
+ await new Promise((resolve) => setTimeout(resolve, 250));
69
+ }
70
+ const state = walletState(observed, input);
71
+ throw new Error(`Mobile wallet reset did not reach fresh onboarding; route=${state.route || 'unknown'} account=${state.address || 'none'}.`);
72
+ }
73
+
74
+ export async function resetMobileWalletThroughUi(input) {
75
+ const timeoutMs = Number(input.node?.timeout_ms ?? 60000);
76
+ const before = await bridgeCommand(input, ['status-selected']);
77
+ if (await isFreshWallet(before, input)) {
78
+ return {
79
+ alreadyFresh: true,
80
+ route: walletState(before, input).route,
81
+ redacted: true,
82
+ proofPath: 'mobile-visible-wallet-reset',
83
+ };
84
+ }
85
+
86
+ if (walletState(before, input).route !== 'Login') {
87
+ await bridgeCommand(input, ['navigate', 'Login']);
88
+ }
89
+ await waitForCommand(input, ['press-test-id', TEST_IDS.forgotPassword], timeoutMs, TEST_IDS.forgotPassword);
90
+ await waitForCommand(input, ['press-test-id', TEST_IDS.resetWallet], timeoutMs, TEST_IDS.resetWallet);
91
+ await waitForCommand(input, ['press-test-id', TEST_IDS.confirmReset], timeoutMs, TEST_IDS.confirmReset);
92
+ const after = await waitForFreshWallet(input, timeoutMs);
93
+ return {
94
+ alreadyFresh: false,
95
+ route: walletState(after, input).route,
96
+ redacted: true,
97
+ proofPath: 'mobile-visible-wallet-reset',
98
+ };
99
+ }
@@ -0,0 +1,20 @@
1
+ import { runAdapter } from '../platform/bridge.mjs';
2
+ import { resetMobileWalletThroughUi } from './reset-helper.mjs';
3
+ import { isOpaqueMobileRuntime, resetWalletThroughNativeUi } from './native-ui.mjs';
4
+
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);