@deeeed/metamask-harness 0.43.0 → 0.44.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.
@@ -2,6 +2,7 @@ import { pathToFileURL } from 'node:url';
2
2
  import {
3
3
  bridgeCommand,
4
4
  evalAsync,
5
+ isTargetTransition,
5
6
  navigate,
6
7
  runAdapter,
7
8
  } from '../platform/bridge.mjs';
@@ -69,6 +70,126 @@ export async function measureHomepageVisible(input) {
69
70
  };
70
71
  }
71
72
 
73
+ const MODE_TEST_IDS = {
74
+ lite: 'perps-mode-toggle-lite',
75
+ pro: 'perps-mode-toggle-pro',
76
+ };
77
+ const MODE_ROOT_TEST_IDS = {
78
+ lite: 'perps-market-details-view',
79
+ pro: 'perps-pro-market-scroll-view',
80
+ };
81
+ const READ_ATTEMPT_TIMEOUT_MS = 10_000;
82
+
83
+ function withRemainingDeadline(input, deadline, attemptTimeoutMs) {
84
+ const remainingMs = Math.max(1, deadline - Date.now());
85
+ const boundedTimeout = (configured) =>
86
+ Math.min(
87
+ remainingMs,
88
+ Number(configured ?? remainingMs),
89
+ attemptTimeoutMs ?? remainingMs,
90
+ );
91
+ return {
92
+ ...input,
93
+ node: {
94
+ ...input.node,
95
+ _action_deadline_epoch_ms: deadline,
96
+ bridge_timeout_ms: boundedTimeout(input.node?.bridge_timeout_ms),
97
+ cdp_timeout_ms: boundedTimeout(input.node?.cdp_timeout_ms),
98
+ controller_ready_timeout_ms: boundedTimeout(
99
+ input.node?.controller_ready_timeout_ms,
100
+ ),
101
+ },
102
+ };
103
+ }
104
+
105
+ async function isModePresent(input, mode, deadline) {
106
+ const rootTarget = JSON.stringify({
107
+ testId: MODE_ROOT_TEST_IDS[mode],
108
+ visibility: 'viewport',
109
+ });
110
+ const controlTarget = JSON.stringify({
111
+ testId: MODE_TEST_IDS[mode],
112
+ visibility: 'tree',
113
+ });
114
+ const result = await evalAsync(
115
+ withRemainingDeadline(input, deadline, READ_ATTEMPT_TIMEOUT_MS),
116
+ `(function(){
117
+ const api = globalThis.__AGENTIC__;
118
+ if (!api || typeof api.queryUiTarget !== 'function') {
119
+ return Promise.resolve(JSON.stringify({ unsupported: true }));
120
+ }
121
+ return Promise.all([
122
+ api.queryUiTarget(${rootTarget}),
123
+ api.queryUiTarget(${controlTarget})
124
+ ]).then(function(values){
125
+ return JSON.stringify({ root: values[0], control: values[1] });
126
+ });
127
+ })()`,
128
+ );
129
+ if (result?.unsupported) {
130
+ throw new Error('metamask.perps.ensure_mode requires UI target queries.');
131
+ }
132
+ return result?.root?.visible === true && result?.control?.present === true;
133
+ }
134
+
135
+ async function readModePresence(input, mode, deadline) {
136
+ try {
137
+ return await isModePresent(input, mode, deadline);
138
+ } catch (error) {
139
+ if (!isTargetTransition(error)) throw error;
140
+ return null;
141
+ }
142
+ }
143
+
144
+ export async function ensureMode(input) {
145
+ const requestedMode = String(input.node?.mode ?? '');
146
+ if (!(requestedMode in MODE_TEST_IDS)) {
147
+ throw new Error('metamask.perps.ensure_mode requires mode=lite|pro.');
148
+ }
149
+ const otherMode = requestedMode === 'lite' ? 'pro' : 'lite';
150
+ const deadline = Date.now() + Number(input.node?.timeout_ms ?? 30_000);
151
+ let switched = false;
152
+
153
+ while (Date.now() < deadline) {
154
+ const requestedModePresent = await readModePresence(
155
+ input,
156
+ requestedMode,
157
+ deadline,
158
+ );
159
+ if (requestedModePresent) {
160
+ return {
161
+ action: input.action,
162
+ mode: requestedMode,
163
+ alreadySelected: !switched,
164
+ proofPath: 'visible-market-detail-root-and-active-mode-control',
165
+ };
166
+ }
167
+ if (Date.now() >= deadline) break;
168
+ if (requestedModePresent !== null && !switched) {
169
+ const otherModePresent = await readModePresence(
170
+ input,
171
+ otherMode,
172
+ deadline,
173
+ );
174
+ if (otherModePresent && Date.now() < deadline) {
175
+ // The visible control names the mode it will switch to, not the mode
176
+ // currently selected.
177
+ const pressed = await bridgeCommand(withRemainingDeadline(input, deadline), [
178
+ 'press-test-id',
179
+ MODE_TEST_IDS[otherMode],
180
+ ]);
181
+ if (pressed?.ok === false) {
182
+ throw new Error(String(pressed.error || 'Perps mode control press failed.'));
183
+ }
184
+ switched = true;
185
+ }
186
+ }
187
+ await sleep(Math.min(250, Math.max(0, deadline - Date.now())));
188
+ }
189
+
190
+ throw new Error(`Perps did not reach ${requestedMode} mode before timeout.`);
191
+ }
192
+
72
193
  export async function retryPerpsClientRead(
73
194
  read,
74
195
  { timeoutMs = 20000, intervalMs = 500, sleep: wait = sleep } = {},
@@ -1074,6 +1195,7 @@ const DIRECT_ACTIONS = new Map([
1074
1195
  ['metamask.perps.ensure_positions', ensurePositions],
1075
1196
  ['metamask.perps.ensure_orders', ensureOrders],
1076
1197
  ['metamask.perps.clear_performance_caches', clearPerformanceCaches],
1198
+ ['metamask.perps.ensure_mode', ensureMode],
1077
1199
  ['metamask.perps.measure_homepage_visible', measureHomepageVisible],
1078
1200
  ['metamask.perps.start_state', startState],
1079
1201
  ['metamask.perps.teardown_state', teardownState],
@@ -26,6 +26,7 @@ const { resolvePort } = configModule;
26
26
  export const MOBILE_BRIDGE_ERROR_CODES = BRIDGE_ERROR_CODES;
27
27
 
28
28
  const execFileAsync = promisify(execFile);
29
+ const ACTION_DEADLINE_FIELD = '_action_deadline_epoch_ms';
29
30
 
30
31
  let adapterActionActive = false;
31
32
  let actionBridgeLockPath = null;
@@ -117,14 +118,17 @@ function runtimeDir() {
117
118
  * Returns the trimmed ro.product.model value, or null when adb is unavailable
118
119
  * or the serial does not respond. Uses execFile (never shell interpolation).
119
120
  */
120
- async function resolveAndroidModel(adbSerial) {
121
+ async function resolveAndroidModel(adbSerial, timeoutMs = 5_000) {
121
122
  const adbPath = resolveMobileToolPath('adb');
122
- if (!adbPath) return null;
123
+ if (!adbPath || timeoutMs <= 0) return null;
123
124
  try {
124
125
  const { stdout } = await execFileAsync(
125
126
  adbPath,
126
127
  ['-s', adbSerial, 'shell', 'getprop', 'ro.product.model'],
127
- { timeout: 5000, encoding: 'utf8' },
128
+ {
129
+ timeout: Math.max(1, Math.min(5_000, Math.floor(timeoutMs))),
130
+ encoding: 'utf8',
131
+ },
128
132
  );
129
133
  const model = stdout.trim();
130
134
  return model || null;
@@ -186,7 +190,11 @@ export async function bridgeEnv(input) {
186
190
  androidTargetDeviceName,
187
191
  )
188
192
  ) {
189
- const model = await resolveAndroidModel(serialStr);
193
+ const deadline = Number(input.node?.[ACTION_DEADLINE_FIELD]);
194
+ const modelTimeoutMs = Number.isFinite(deadline)
195
+ ? deadline - Date.now()
196
+ : 5_000;
197
+ const model = await resolveAndroidModel(serialStr, modelTimeoutMs);
190
198
  if (model) {
191
199
  env.ANDROID_TARGET_DEVICE_NAME = model;
192
200
  }
@@ -432,7 +440,6 @@ const TRANSIENT_NULL_COMMANDS = new Set(['get-route']);
432
440
 
433
441
  export async function bridgeCommand(input, args) {
434
442
  const script = bridgeScript(input);
435
- // bridgeEnv is async: it may call `adb getprop` to resolve the Metro device name.
436
443
  const env = await bridgeEnv(input);
437
444
  const brokerSocket = brokerSocketPath(
438
445
  path.dirname(resolveBridgeLockPath(input, env)),
@@ -441,8 +448,33 @@ export async function bridgeCommand(input, args) {
441
448
  if (adapterActionActive && !existsSync(brokerSocket)) {
442
449
  env.CDP_BRIDGE_LOCK_OWNER_PID = await acquireActionBridgeLock(input, env);
443
450
  }
451
+ const actionDeadline = Number(input.node?.[ACTION_DEADLINE_FIELD]);
452
+ const configuredTimeoutValue = Number(
453
+ input.node?.bridge_timeout_ms ??
454
+ input.node?.cdp_timeout_ms ??
455
+ process.env.CDP_TIMEOUT ??
456
+ 30000,
457
+ );
458
+ const configuredTimeoutMs =
459
+ Number.isFinite(configuredTimeoutValue) && configuredTimeoutValue > 0
460
+ ? configuredTimeoutValue
461
+ : 30_000;
462
+ const remainingActionMs = Number.isFinite(actionDeadline)
463
+ ? actionDeadline - Date.now()
464
+ : configuredTimeoutMs;
465
+ if (remainingActionMs <= 0) {
466
+ throw coded(
467
+ new Error(
468
+ `Mobile CDP bridge command did not start before its action deadline: ${String(args[0] ?? 'unknown')}`,
469
+ ),
470
+ BRIDGE_ERROR_CODES.CDP_TIMEOUT,
471
+ );
472
+ }
473
+ const timeoutMs = Math.max(
474
+ 1,
475
+ Math.min(configuredTimeoutMs, remainingActionMs),
476
+ );
444
477
  const result = await new Promise((resolve, reject) => {
445
- const timeoutMs = Number(input.node?.bridge_timeout_ms ?? input.node?.cdp_timeout_ms ?? process.env.CDP_TIMEOUT ?? 30000);
446
478
  const child = spawn(process.execPath, [script, ...args], {
447
479
  cwd: input.context.projectRoot,
448
480
  env: { ...env, APP_ROOT: input.context.projectRoot },
@@ -567,7 +599,7 @@ export async function evalAsync(input, expression) {
567
599
  } catch (error) {
568
600
  lastError = error;
569
601
  if (!String(error?.message ?? error).includes('CLIENT_NOT_INITIALIZED')) throw error;
570
- await sleep(500);
602
+ await sleep(Math.min(500, Math.max(0, deadline - Date.now())));
571
603
  }
572
604
  }
573
605
  throw lastError ?? new Error('Timed out waiting for Mobile async evaluation.');
@@ -583,7 +615,7 @@ const TARGET_TRANSITION_CODES = new Set([
583
615
  BRIDGE_ERROR_CODES.WS_CLOSED,
584
616
  ]);
585
617
 
586
- function isTargetTransition(error) {
618
+ export function isTargetTransition(error) {
587
619
  return TARGET_TRANSITION_CODES.has(error?.code);
588
620
  }
589
621
 
@@ -9,7 +9,7 @@ import {
9
9
  export function createNativeSession(input, domain, sessionSuffix = '') {
10
10
  const env = { ...process.env, ...(input.context?.env ?? {}) };
11
11
  const device = String(env.ADB_SERIAL ?? env.ANDROID_SERIAL ?? '').trim();
12
- if (!device) throw new Error(`Opaque Mobile ${domain} actions require one explicit Android device serial.`);
12
+ if (!device) throw new Error(`Mobile ${domain} native actions require one explicit Android device serial.`);
13
13
  const app = String(env.ANDROID_PACKAGE_ID ?? 'io.metamask');
14
14
  const baseSession = nativeSessionName(env, device, app);
15
15
  const session = sessionSuffix
@@ -1,10 +1,7 @@
1
1
  import { runAdapter } from '../platform/bridge.mjs';
2
- import { isOpaqueMobileRuntime, lockWalletThroughNativeUi } from './native-ui.mjs';
2
+ import { lockWalletThroughNativeUi } from './native-ui.mjs';
3
3
 
4
4
  runAdapter(async (input) => {
5
- if (!isOpaqueMobileRuntime(input)) {
6
- throw new Error('metamask.wallet.lock requires an opaque Mobile runtime.');
7
- }
8
5
  return {
9
6
  action: input.action,
10
7
  ...await lockWalletThroughNativeUi(input),
@@ -1,7 +1,87 @@
1
1
  import { pathToFileURL } from 'node:url';
2
- import { bridgeCommand, runAdapter } from '../platform/bridge.mjs';
2
+ import {
3
+ bridgeCommand,
4
+ MOBILE_BRIDGE_ERROR_CODES,
5
+ runAdapter,
6
+ } from '../platform/bridge.mjs';
3
7
 
4
8
  const sleep = (ms) => new Promise((resolve) => setTimeout(resolve, ms));
9
+ const TRANSIENT_READ_CODES = new Set([
10
+ MOBILE_BRIDGE_ERROR_CODES.NO_TARGET,
11
+ MOBILE_BRIDGE_ERROR_CODES.CDP_TIMEOUT,
12
+ MOBILE_BRIDGE_ERROR_CODES.WS_CLOSED,
13
+ ]);
14
+ const READ_ATTEMPT_TIMEOUT_MS = 10_000;
15
+
16
+ function withRemainingDeadline(input, deadline, attemptTimeoutMs) {
17
+ const remainingMs = Math.max(1, deadline - Date.now());
18
+ const boundedTimeout = (configured) =>
19
+ Math.min(
20
+ remainingMs,
21
+ Number(configured ?? remainingMs),
22
+ attemptTimeoutMs ?? remainingMs,
23
+ );
24
+ return {
25
+ ...input,
26
+ node: {
27
+ ...input.node,
28
+ _action_deadline_epoch_ms: deadline,
29
+ bridge_timeout_ms: boundedTimeout(input.node?.bridge_timeout_ms),
30
+ cdp_timeout_ms: boundedTimeout(input.node?.cdp_timeout_ms),
31
+ },
32
+ };
33
+ }
34
+
35
+ async function readSelectedAccount(input, deadline) {
36
+ let lastError = null;
37
+ while (Date.now() < deadline) {
38
+ try {
39
+ return await bridgeCommand(
40
+ withRemainingDeadline(input, deadline, READ_ATTEMPT_TIMEOUT_MS),
41
+ ['get-selected-account'],
42
+ );
43
+ } catch (error) {
44
+ if (!TRANSIENT_READ_CODES.has(error?.code)) throw error;
45
+ lastError = error;
46
+ await sleep(Math.min(100, Math.max(0, deadline - Date.now())));
47
+ }
48
+ }
49
+ throw lastError ?? new Error('Timed out reading the selected Mobile account.');
50
+ }
51
+
52
+ async function readAccounts(input, deadline) {
53
+ let lastError = null;
54
+ while (Date.now() < deadline) {
55
+ try {
56
+ return await bridgeCommand(
57
+ withRemainingDeadline(input, deadline, READ_ATTEMPT_TIMEOUT_MS),
58
+ ['list-accounts'],
59
+ );
60
+ } catch (error) {
61
+ if (!TRANSIENT_READ_CODES.has(error?.code)) throw error;
62
+ lastError = error;
63
+ await sleep(Math.min(100, Math.max(0, deadline - Date.now())));
64
+ }
65
+ }
66
+ throw lastError ?? new Error('Timed out reading Mobile accounts.');
67
+ }
68
+
69
+ function ambiguousWriteFailure(writeError, match, finalSelected, readError) {
70
+ const failure = new Error(
71
+ `Mobile account switch to ${match.address} returned an ambiguous ${String(writeError.code)} error and did not converge before the deadline; ` +
72
+ `writeError=${String(writeError.message ?? writeError)}; finalSelectedAccount=${JSON.stringify(finalSelected)}${
73
+ readError
74
+ ? `; finalReadError=${String(readError.message ?? readError)}`
75
+ : ''
76
+ }.`,
77
+ { cause: writeError },
78
+ );
79
+ failure.code = writeError.code;
80
+ failure.writeError = writeError;
81
+ failure.finalSelectedAccount = finalSelected;
82
+ if (readError) failure.finalReadError = readError;
83
+ return failure;
84
+ }
5
85
 
6
86
  export async function selectAccount(input) {
7
87
  const requestedAddress = input.node?.address ? String(input.node.address).toLowerCase() : null;
@@ -10,7 +90,9 @@ export async function selectAccount(input) {
10
90
  if (!requestedAddress && !requestedId && !requestedName) {
11
91
  throw new Error('metamask.wallet.select_account mobile live adapter requires node.address, node.id, or node.name.');
12
92
  }
13
- const accounts = await bridgeCommand(input, ['list-accounts']);
93
+ const timeoutMs = Number(input.node?.timeout_ms ?? 30000);
94
+ const deadline = Date.now() + timeoutMs;
95
+ const accounts = await readAccounts(input, deadline);
14
96
  if (!Array.isArray(accounts)) {
15
97
  throw new Error(`metamask.wallet.select_account expected list-accounts to return an array, got ${typeof accounts}.`);
16
98
  }
@@ -25,7 +107,7 @@ export async function selectAccount(input) {
25
107
  if (!match?.address) {
26
108
  throw new Error(`Requested mobile account was not found in list-accounts: ${JSON.stringify({ address: requestedAddress, id: requestedId, name: requestedName })}`);
27
109
  }
28
- const current = await bridgeCommand(input, ['get-selected-account']);
110
+ const current = await readSelectedAccount(input, deadline);
29
111
  const matchAddress = String(match.address).toLowerCase();
30
112
  if (String(current?.address ?? current ?? '').toLowerCase() === matchAddress) {
31
113
  return {
@@ -39,29 +121,59 @@ export async function selectAccount(input) {
39
121
  proofPath: 'agentic-account-selection',
40
122
  };
41
123
  }
42
- const result = await bridgeCommand(input, ['switch-account', String(match.address)]);
43
- const timeoutMs = Number(input.node?.timeout_ms ?? 5000);
44
- const deadline = Date.now() + timeoutMs;
45
- let selected = null;
46
- do {
47
- selected = await bridgeCommand(input, ['get-selected-account']);
48
- if (String(selected?.address ?? selected ?? '').toLowerCase() === String(match.address).toLowerCase()) {
49
- break;
50
- }
51
- await sleep(100);
52
- } while (Date.now() < deadline);
124
+ if (Date.now() >= deadline) {
125
+ throw new Error(
126
+ `Mobile account selection did not leave enough time to switch to ${match.address} within ${timeoutMs}ms.`,
127
+ );
128
+ }
129
+ let result = null;
130
+ let ambiguousWriteError = null;
131
+ try {
132
+ result = await bridgeCommand(withRemainingDeadline(input, deadline), [
133
+ 'switch-account',
134
+ String(match.address),
135
+ ]);
136
+ } catch (error) {
137
+ if (!TRANSIENT_READ_CODES.has(error?.code)) throw error;
138
+ ambiguousWriteError = error;
139
+ }
140
+ let selected = current;
141
+ let finalReadError = null;
142
+ try {
143
+ do {
144
+ selected = await readSelectedAccount(input, deadline);
145
+ if (String(selected?.address ?? selected ?? '').toLowerCase() === String(match.address).toLowerCase()) {
146
+ break;
147
+ }
148
+ await sleep(Math.min(100, Math.max(0, deadline - Date.now())));
149
+ } while (Date.now() < deadline);
150
+ } catch (error) {
151
+ if (!ambiguousWriteError) throw error;
152
+ finalReadError = error;
153
+ }
53
154
 
54
155
  if (String(selected?.address ?? selected ?? '').toLowerCase() !== String(match.address).toLowerCase()) {
156
+ if (ambiguousWriteError) {
157
+ throw ambiguousWriteFailure(
158
+ ambiguousWriteError,
159
+ match,
160
+ selected,
161
+ finalReadError,
162
+ );
163
+ }
55
164
  throw new Error(`Mobile account selection did not converge to ${match.address} after ${timeoutMs}ms.`);
56
165
  }
57
166
 
58
167
  return {
59
168
  action: input.action,
60
169
  selected: {
61
- address: selected?.address ?? result.address,
62
- id: selected?.id ?? result.id,
63
- name: selected?.name ?? result.name,
170
+ address: selected?.address ?? result?.address ?? match.address,
171
+ id: selected?.id ?? result?.id ?? match.id,
172
+ name: selected?.name ?? result?.name ?? match.name,
64
173
  },
174
+ ...(ambiguousWriteError
175
+ ? { recoveredAfterAmbiguousWrite: true }
176
+ : {}),
65
177
  proofPath: 'agentic-account-selection',
66
178
  };
67
179
  }
@@ -1618,7 +1618,7 @@
1618
1618
  "execution_capabilities": ["app-mutation"]
1619
1619
  },
1620
1620
  "metamask.wallet.lock": {
1621
- "description": "Lock the Mobile wallet through the visible menu and confirm the login screen.",
1621
+ "description": "Lock the Android Mobile wallet through the visible menu and confirm the login screen on development or opaque clients.",
1622
1622
  "schema": {
1623
1623
  "type": "object",
1624
1624
  "properties": {
@@ -3115,6 +3115,9 @@
3115
3115
  "wait_for_lifecycle": {
3116
3116
  "type": "string"
3117
3117
  },
3118
+ "wait_for_content_variant": {
3119
+ "type": "string"
3120
+ },
3118
3121
  "wait_timeout_ms": {
3119
3122
  "type": "number",
3120
3123
  "minimum": 1
@@ -3144,7 +3147,7 @@
3144
3147
  },
3145
3148
  "required_live_streams": {
3146
3149
  "type": "array",
3147
- "description": "Require values_ready records sourced from fresh_socket for each named Perps stream.",
3150
+ "description": "Require app-emitted [PerpsLoadProof] values_ready records from fresh_socket with one complete session, lifecycle, account, context, and connection-generation identity.",
3148
3151
  "items": {
3149
3152
  "type": "string",
3150
3153
  "enum": ["prices", "positions", "orders", "account"]
@@ -3213,7 +3216,7 @@
3213
3216
  {
3214
3217
  "action": "metamask.perps.capture_performance",
3215
3218
  "phase": "end",
3216
- "require_records": false,
3219
+ "require_records": true,
3217
3220
  "required_live_streams": ["positions", "orders", "account"],
3218
3221
  "intent": "Preserve production Perps loading and live-stream evidence emitted during this run",
3219
3222
  "next": "done"
@@ -3290,6 +3293,28 @@
3290
3293
  ],
3291
3294
  "execution_capabilities": ["app-mutation"]
3292
3295
  },
3296
+ "metamask.perps.ensure_mode": {
3297
+ "description": "Ensure the viewport-visible Perps market-detail root is Lite or Pro. The action presses the active mode control at most once and fails closed if that press opens another chooser or does not converge directly.",
3298
+ "schema": {
3299
+ "type": "object",
3300
+ "properties": {
3301
+ "mode": {
3302
+ "type": "string",
3303
+ "enum": ["lite", "pro"]
3304
+ },
3305
+ "timeout_ms": { "type": "integer", "minimum": 1000, "default": 30000 }
3306
+ },
3307
+ "required": ["mode"],
3308
+ "additionalProperties": false
3309
+ },
3310
+ "examples": [{
3311
+ "action": "metamask.perps.ensure_mode",
3312
+ "mode": "pro",
3313
+ "intent": "Reach Pro through the visible market-detail mode control",
3314
+ "next": "done"
3315
+ }],
3316
+ "execution_capabilities": ["app-mutation"]
3317
+ },
3293
3318
  "metamask.perps.start_state": {
3294
3319
  "description": "mobile Default to testnet for Perps mutations; mainnet is read-only unless explicitly requested. Recommended configurable Perps start state that first restores the fixture-backed unlocked wallet, then composes reusable domain operations before the proof window.",
3295
3320
  "schema": {