@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,98 @@
1
+ import { runAdapter, withExtensionPage } from '../platform/cdp.mjs';
2
+ import { readWalletStateExpression } from './state.mjs';
3
+
4
+ const SELECTORS = {
5
+ accountMenu: '[data-testid="account-options-menu-button"]',
6
+ lock: '[data-testid="global-menu-lock"]',
7
+ forgotPassword: '[data-testid="unlock-forgot-password-button"]',
8
+ resetChoice: '[data-testid="reset-password-modal-button-link"]',
9
+ confirmReset: '[data-testid="reset-password-modal-button"]',
10
+ };
11
+
12
+ async function hasSelector(page, selector) {
13
+ return page.evaluate(`Boolean(document.querySelector(${JSON.stringify(selector)}))`).catch(() => false);
14
+ }
15
+
16
+ async function waitForSelector(page, selector, timeoutMs) {
17
+ const deadline = Date.now() + timeoutMs;
18
+ while (Date.now() <= deadline) {
19
+ if (await hasSelector(page, selector)) return;
20
+ await new Promise((resolve) => setTimeout(resolve, 100));
21
+ }
22
+ throw new Error(`Wallet reset timed out waiting for ${selector}.`);
23
+ }
24
+
25
+ async function clickWhenReady(page, selector, timeoutMs) {
26
+ const deadline = Date.now() + timeoutMs;
27
+ let lastError;
28
+ while (Date.now() <= deadline) {
29
+ if (await hasSelector(page, selector)) {
30
+ try {
31
+ await page.click(selector);
32
+ return;
33
+ } catch (error) {
34
+ const message = String(error?.message ?? error);
35
+ if (!message.includes('Target is obscured') && !message.includes('Target is not visible')) {
36
+ throw error;
37
+ }
38
+ lastError = error;
39
+ }
40
+ }
41
+ await new Promise((resolve) => setTimeout(resolve, 100));
42
+ }
43
+ throw lastError ?? new Error(`Wallet reset timed out waiting for ${selector}.`);
44
+ }
45
+
46
+ async function readState(page) {
47
+ return page.evaluate(readWalletStateExpression(), { awaitPromise: true });
48
+ }
49
+
50
+ function isFreshWallet(state) {
51
+ return !state.completedOnboarding &&
52
+ String(state.href ?? '').includes('/onboarding') &&
53
+ !state.selectedAccount;
54
+ }
55
+
56
+ async function waitForFreshWallet(page, timeoutMs) {
57
+ const deadline = Date.now() + timeoutMs;
58
+ let observed;
59
+ while (Date.now() <= deadline) {
60
+ observed = await readState(page);
61
+ if (isFreshWallet(observed)) return observed;
62
+ await new Promise((resolve) => setTimeout(resolve, 100));
63
+ }
64
+ throw new Error('Extension wallet reset did not clear onboarding and the selected account.');
65
+ }
66
+
67
+ runAdapter((input) => withExtensionPage(input, async (page) => {
68
+ const timeoutMs = Number(input.node?.timeout_ms ?? 30000);
69
+ const before = await readState(page);
70
+ if (isFreshWallet(before)) {
71
+ return {
72
+ action: input.action,
73
+ alreadyFresh: true,
74
+ completedOnboarding: false,
75
+ redacted: true,
76
+ proofPath: 'extension-visible-wallet-reset',
77
+ };
78
+ }
79
+
80
+ if (!before.passwordInput) {
81
+ await page.navigateHash('#/');
82
+ await waitForSelector(page, SELECTORS.accountMenu, timeoutMs);
83
+ await clickWhenReady(page, SELECTORS.accountMenu, timeoutMs);
84
+ await clickWhenReady(page, SELECTORS.lock, timeoutMs);
85
+ await waitForSelector(page, SELECTORS.forgotPassword, timeoutMs);
86
+ }
87
+ await clickWhenReady(page, SELECTORS.forgotPassword, timeoutMs);
88
+ await clickWhenReady(page, SELECTORS.resetChoice, timeoutMs);
89
+ await clickWhenReady(page, SELECTORS.confirmReset, timeoutMs);
90
+ const after = await waitForFreshWallet(page, timeoutMs);
91
+ return {
92
+ action: input.action,
93
+ alreadyFresh: false,
94
+ completedOnboarding: after.completedOnboarding,
95
+ redacted: true,
96
+ proofPath: 'extension-visible-wallet-reset',
97
+ };
98
+ }));
@@ -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
+ }
@@ -38,6 +38,7 @@ export function readWalletStateExpression() {
38
38
  type: selected.type || null,
39
39
  } : null,
40
40
  completedOnboarding: Boolean(metamask.completedOnboarding),
41
+ metametricsOptedIn: typeof metamask.optedIn === 'boolean' ? metamask.optedIn : null,
41
42
  selectedNetworkClientId: metamask.selectedNetworkClientId || null,
42
43
  };
43
44
  })()`;
@@ -0,0 +1,112 @@
1
+ import { evalAsync, navigate } from '../platform/bridge.mjs';
2
+
3
+ const SWITCH_IDS = {
4
+ participate: 'metametrics-switch',
5
+ marketing: 'data-collection-switch',
6
+ };
7
+
8
+ function toggleExpression(testId, expected, invoke) {
9
+ return `(() => {
10
+ const find = () => {
11
+ const hook = globalThis.__REACT_DEVTOOLS_GLOBAL_HOOK__;
12
+ const rootsFor = hook?.getFiberRoots;
13
+ if (!hook?.renderers || typeof rootsFor !== 'function') return null;
14
+ const walk = (fiber) => {
15
+ if (!fiber) return null;
16
+ if (fiber.memoizedProps?.testID === ${JSON.stringify(testId)}) return fiber;
17
+ return walk(fiber.child) || walk(fiber.sibling);
18
+ };
19
+ for (const [id] of hook.renderers) {
20
+ for (const root of rootsFor(id) ?? []) {
21
+ const match = walk(root.current);
22
+ if (match) return match;
23
+ }
24
+ }
25
+ return null;
26
+ };
27
+ const target = find();
28
+ const props = target?.memoizedProps;
29
+ if (!props) return { matched: false, found: false, invoked: false };
30
+ if (Boolean(props.value) === ${JSON.stringify(expected)}) {
31
+ return { matched: true, found: true, invoked: false };
32
+ }
33
+ if (!${JSON.stringify(invoke)}) {
34
+ return { matched: false, found: true, invoked: false };
35
+ }
36
+ if (typeof props.onValueChange !== 'function') {
37
+ throw new Error('Consent switch has no onValueChange handler: ${testId}');
38
+ }
39
+ return Promise.resolve(
40
+ props.onValueChange(${JSON.stringify(expected)})
41
+ ).then(() => ({
42
+ matched: false,
43
+ found: true,
44
+ invoked: true
45
+ }));
46
+ })()`;
47
+ }
48
+
49
+ function stateExpression(participate, marketing) {
50
+ return `(() => {
51
+ const state = globalThis.store?.getState?.();
52
+ const analytics = state?.engine?.backgroundState?.AnalyticsController ?? {};
53
+ const consent = {
54
+ optedIn: Boolean(analytics.optedIn),
55
+ dataCollectionForMarketing: Boolean(state?.security?.dataCollectionForMarketing),
56
+ analyticsId: analytics.analyticsId ? 'set' : null
57
+ };
58
+ return {
59
+ ready:
60
+ consent.optedIn === ${JSON.stringify(participate)} &&
61
+ consent.dataCollectionForMarketing === ${JSON.stringify(marketing)} &&
62
+ (!${JSON.stringify(participate)} || consent.analyticsId === 'set'),
63
+ consent
64
+ };
65
+ })()`;
66
+ }
67
+
68
+ function delay(ms) {
69
+ return new Promise((resolve) => setTimeout(resolve, ms));
70
+ }
71
+
72
+ async function setSwitch(input, testId, expected, timeoutMs) {
73
+ const deadline = Date.now() + timeoutMs;
74
+ let invoked = false;
75
+ while (Date.now() < deadline) {
76
+ const result = await evalAsync(
77
+ input,
78
+ toggleExpression(testId, expected, !invoked),
79
+ );
80
+ invoked ||= Boolean(result?.invoked);
81
+ if (result?.matched) {
82
+ return { ...result, changed: invoked };
83
+ }
84
+ await delay(100);
85
+ }
86
+ throw new Error(`Timed out setting consent switch ${testId} to ${expected}.`);
87
+ }
88
+
89
+ async function readConsent(input, participate, marketing, timeoutMs) {
90
+ const deadline = Date.now() + timeoutMs;
91
+ while (Date.now() < deadline) {
92
+ const result = await evalAsync(
93
+ input,
94
+ stateExpression(participate, marketing),
95
+ );
96
+ if (result?.ready) {
97
+ return result.consent;
98
+ }
99
+ await delay(100);
100
+ }
101
+ throw new Error('Timed out reading back Mobile analytics consent.');
102
+ }
103
+
104
+ export async function setMobileConsent(input, participate, marketing, timeoutMs) {
105
+ const navigation = await navigate(input, 'SettingsView', {
106
+ screen: 'SecuritySettings',
107
+ }, 'SecuritySettings');
108
+ await setSwitch(input, SWITCH_IDS.participate, participate, timeoutMs);
109
+ await setSwitch(input, SWITCH_IDS.marketing, marketing, timeoutMs);
110
+ const consent = await readConsent(input, participate, marketing, timeoutMs);
111
+ return { consent, navigation };
112
+ }
@@ -1,120 +1,12 @@
1
- import { evalAsync, navigate, runAdapter } from '../platform/bridge.mjs';
1
+ import { runAdapter } from '../platform/bridge.mjs';
2
2
  import { consentParams } from '../../shared/analytics/consent.mjs';
3
-
4
- const SWITCH_IDS = {
5
- participate: 'metametrics-switch',
6
- marketing: 'data-collection-switch',
7
- };
8
-
9
- function toggleExpression(testId, expected, invoke) {
10
- return `(() => {
11
- const find = () => {
12
- const hook = globalThis.__REACT_DEVTOOLS_GLOBAL_HOOK__;
13
- const rootsFor = hook?.getFiberRoots;
14
- if (!hook?.renderers || typeof rootsFor !== 'function') return null;
15
- const walk = (fiber) => {
16
- if (!fiber) return null;
17
- if (fiber.memoizedProps?.testID === ${JSON.stringify(testId)}) return fiber;
18
- return walk(fiber.child) || walk(fiber.sibling);
19
- };
20
- for (const [id] of hook.renderers) {
21
- for (const root of rootsFor(id) ?? []) {
22
- const match = walk(root.current);
23
- if (match) return match;
24
- }
25
- }
26
- return null;
27
- };
28
- const target = find();
29
- const props = target?.memoizedProps;
30
- if (!props) return { matched: false, found: false, invoked: false };
31
- if (Boolean(props.value) === ${JSON.stringify(expected)}) {
32
- return { matched: true, found: true, invoked: false };
33
- }
34
- if (!${JSON.stringify(invoke)}) {
35
- return { matched: false, found: true, invoked: false };
36
- }
37
- if (typeof props.onValueChange !== 'function') {
38
- throw new Error('Consent switch has no onValueChange handler: ${testId}');
39
- }
40
- return Promise.resolve(
41
- props.onValueChange(${JSON.stringify(expected)})
42
- ).then(() => ({
43
- matched: false,
44
- found: true,
45
- invoked: true
46
- }));
47
- })()`;
48
- }
49
-
50
- function stateExpression(participate, marketing) {
51
- return `(() => {
52
- const state = globalThis.store?.getState?.();
53
- const analytics = state?.engine?.backgroundState?.AnalyticsController ?? {};
54
- const consent = {
55
- optedIn: Boolean(analytics.optedIn),
56
- dataCollectionForMarketing: Boolean(state?.security?.dataCollectionForMarketing),
57
- analyticsId: analytics.analyticsId ? 'set' : null
58
- };
59
- return {
60
- ready:
61
- consent.optedIn === ${JSON.stringify(participate)} &&
62
- consent.dataCollectionForMarketing === ${JSON.stringify(marketing)} &&
63
- (!${JSON.stringify(participate)} || consent.analyticsId === 'set'),
64
- consent
65
- };
66
- })()`;
67
- }
68
-
69
- function delay(ms) {
70
- return new Promise((resolve) => setTimeout(resolve, ms));
71
- }
72
-
73
- async function setSwitch(input, testId, expected, timeoutMs) {
74
- const deadline = Date.now() + timeoutMs;
75
- let invoked = false;
76
- while (Date.now() < deadline) {
77
- const result = await evalAsync(
78
- input,
79
- toggleExpression(testId, expected, !invoked),
80
- );
81
- invoked ||= Boolean(result?.invoked);
82
- if (result?.matched) {
83
- return { ...result, changed: invoked };
84
- }
85
- await delay(100);
86
- }
87
- throw new Error(`Timed out setting consent switch ${testId} to ${expected}.`);
88
- }
89
-
90
- async function readConsent(input, participate, marketing, timeoutMs) {
91
- const deadline = Date.now() + timeoutMs;
92
- while (Date.now() < deadline) {
93
- const result = await evalAsync(
94
- input,
95
- stateExpression(participate, marketing),
96
- );
97
- if (result?.ready) {
98
- return result.consent;
99
- }
100
- await delay(100);
101
- }
102
- throw new Error('Timed out reading back Mobile analytics consent.');
103
- }
3
+ import { setMobileConsent } from './consent-settings.mjs';
104
4
 
105
5
  runAdapter(async (input) => {
106
6
  const { participate, marketing, timeoutMs } = consentParams(input.node);
107
7
 
108
- const navigation = await navigate(input, 'SettingsView', {
109
- screen: 'SecuritySettings',
110
- }, 'SecuritySettings');
111
- await setSwitch(input, SWITCH_IDS.participate, participate, timeoutMs);
112
- await setSwitch(input, SWITCH_IDS.marketing, marketing, timeoutMs);
113
- const consent = await readConsent(
114
- input,
115
- participate,
116
- marketing,
117
- timeoutMs,
8
+ const { consent, navigation } = await setMobileConsent(
9
+ input, participate, marketing, timeoutMs,
118
10
  );
119
11
 
120
12
  return {
@@ -454,6 +454,9 @@ export async function bridgeCommand(input, args) {
454
454
  function redactBridgeArgs(args) {
455
455
  const command = String(args[0] ?? '');
456
456
  if (command === 'unlock' && args.length > 1) return [command, '<redacted-password>', ...args.slice(2)];
457
+ if (command === 'set-input' && isSensitiveInputId(args[1])) {
458
+ return [command, String(args[1]), '<redacted>'];
459
+ }
457
460
  return args.map((arg) => String(arg));
458
461
  }
459
462
 
@@ -470,9 +473,14 @@ function redactBridgeOutput(output, args) {
470
473
  function sensitiveBridgeArgs(args) {
471
474
  const command = String(args[0] ?? '');
472
475
  if (command === 'unlock' && args.length > 1) return [args[1]];
476
+ if (command === 'set-input' && isSensitiveInputId(args[1])) return args.slice(2);
473
477
  return [];
474
478
  }
475
479
 
480
+ function isSensitiveInputId(value) {
481
+ return /(?:confirm-password|password|phrase|seed|srp)/iu.test(String(value ?? ''));
482
+ }
483
+
476
484
  function parseMaybeJson(value) {
477
485
  if (typeof value !== 'string') return value;
478
486
  try {
@@ -1,12 +1,13 @@
1
1
  import { execFile } from 'node:child_process';
2
2
  import { promisify } from 'node:util';
3
+ import { createAgentDeviceClient } from 'agent-device';
3
4
 
4
5
  import { bridgeCommand } from './bridge.mjs';
5
6
  import { mobileToolRecovery, resolveMobileToolPath } from './tool-paths.mjs';
6
7
 
7
8
  const execFileAsync = promisify(execFile);
8
- const VISIBLE_LIMIT = 50;
9
- const HIDDEN_LIMIT = 50;
9
+ const VISIBLE_LIMIT = 200;
10
+ const HIDDEN_LIMIT = 100;
10
11
 
11
12
  export async function observeNativeUi(payload, context) {
12
13
  await hideAccessibilityMaskingHud(payload, context);
@@ -111,6 +112,9 @@ async function readAndroidHierarchy(node, env) {
111
112
  );
112
113
  }
113
114
  const serial = await resolveAndroidSerial(adbPath, node, env);
115
+ if (isOpaqueRuntime(env)) {
116
+ return readAndroidAgentDeviceHierarchy(serial, env);
117
+ }
114
118
  const { stdout } = await execFileAsync(
115
119
  adbPath,
116
120
  ['-s', serial, 'exec-out', 'uiautomator', 'dump', '/dev/tty'],
@@ -135,6 +139,84 @@ async function readAndroidHierarchy(node, env) {
135
139
  );
136
140
  }
137
141
 
142
+ async function readAndroidAgentDeviceHierarchy(serial, env) {
143
+ const session = `mm-harness-observe-${process.pid}-${Date.now()}`;
144
+ const client = createAgentDeviceClient({
145
+ session,
146
+ lockPolicy: 'reject',
147
+ lockPlatform: 'android',
148
+ });
149
+ try {
150
+ const snapshot = await client.capture.snapshot({
151
+ platform: 'android',
152
+ target: 'mobile',
153
+ serial,
154
+ session,
155
+ app: text(env.ANDROID_PACKAGE_ID) ?? 'io.metamask',
156
+ interactiveOnly: false,
157
+ forceFull: true,
158
+ });
159
+ const hierarchy = flattenAgentDeviceHierarchy(
160
+ snapshot.nodes,
161
+ text(env.ANDROID_PACKAGE_ID) ?? 'io.metamask',
162
+ );
163
+ if (hierarchy.nodeCount === 0) {
164
+ throw new Error('Agent Device returned an empty Android accessibility hierarchy.');
165
+ }
166
+ return normalizedHierarchy(
167
+ 'agent-device-accessibility',
168
+ 'android',
169
+ serial,
170
+ hierarchy.visible,
171
+ hierarchy.hidden,
172
+ hierarchy,
173
+ );
174
+ } finally {
175
+ await client.sessions.close({ session, shutdown: false });
176
+ }
177
+ }
178
+
179
+ function flattenAgentDeviceHierarchy(nodes, appId) {
180
+ const visible = [];
181
+ const hidden = [];
182
+ let screenName;
183
+ for (const source of Array.isArray(nodes) ? nodes : []) {
184
+ const node = record(source);
185
+ const bundleId = text(node.bundleId);
186
+ if (bundleId && bundleId !== appId) continue;
187
+ const bounds = normalizeBounds(node.rect ?? node.bounds);
188
+ const label = text(node.label) ?? text(node.value);
189
+ const role = text(node.type)?.toLowerCase();
190
+ const testId = text(node.identifier);
191
+ if (!isActionableRole(role) && !testId) continue;
192
+ screenName ??= label;
193
+ const item = compactItem({
194
+ role,
195
+ label,
196
+ test_id: testId,
197
+ enabled: boolean(node.enabled),
198
+ selected: boolean(node.selected),
199
+ focused: boolean(node.focused),
200
+ bounds,
201
+ });
202
+ if (hasVisibleBounds(bounds)) {
203
+ visible.push(item);
204
+ } else {
205
+ hidden.push({ ...item, reason: 'hidden_or_offscreen' });
206
+ }
207
+ }
208
+ return {
209
+ visible,
210
+ hidden,
211
+ nodeCount: Array.isArray(nodes) ? nodes.length : 0,
212
+ screenName,
213
+ };
214
+ }
215
+
216
+ function isOpaqueRuntime(env) {
217
+ return env.METAMASK_RECIPE_MOBILE_OPAQUE_RUNTIME === '1';
218
+ }
219
+
138
220
  function normalizedHierarchy(
139
221
  provider,
140
222
  platform,