@deeeed/metamask-harness 0.86.2 → 0.87.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 (35) hide show
  1. package/CHANGELOG.md +26 -0
  2. package/dist/adapters/mobile/action-inputs.js +20 -0
  3. package/dist/adapters/mobile/actions.js +18 -1
  4. package/dist/adapters/mobile/android-screenrecord.js +270 -0
  5. package/dist/adapters/mobile/readiness.js +2 -1
  6. package/dist/adapters/mobile/recording-outcome.js +103 -0
  7. package/dist/adapters/mobile/recording-target.js +11 -0
  8. package/dist/adapters/mobile/run.js +48 -11
  9. package/dist/adapters/mobile/ui.js +96 -2
  10. package/dist/adapters/perps-order-inputs.js +43 -28
  11. package/dist/adapters.js +4 -1
  12. package/dist/commands/doctor.js +33 -14
  13. package/dist/commands/status-probe.js +2 -1
  14. package/docs/RECIPES.md +62 -0
  15. package/library/actions/core/perps/read_account.mjs +4 -2
  16. package/library/actions/core/perps/read_orders.mjs +4 -2
  17. package/library/actions/core/perps/read_positions.mjs +4 -2
  18. package/library/actions/extension/analytics/consent.mjs +7 -2
  19. package/library/actions/extension/perps/compare_provider_market.mjs +12 -8
  20. package/library/actions/extension/perps/mutation-receipt.mjs +120 -23
  21. package/library/actions/extension/perps/perps.mjs +98 -30
  22. package/library/actions/extension/perps/read_snapshot.mjs +6 -2
  23. package/library/actions/extension/platform/cdp.mjs +6 -2
  24. package/library/actions/extension/ui/navigate.mjs +8 -0
  25. package/library/actions/mobile/perps/perps.mjs +106 -92
  26. package/library/actions/mobile/platform/bridge.mjs +171 -10
  27. package/library/actions/mobile/wallet/home.mjs +7 -2
  28. package/library/actions/mobile/wallet/select_account.mjs +4 -0
  29. package/library/actions/shared/app/live-adapter-deadline.mjs +8 -0
  30. package/library/actions/shared/perps/rate-limit.mjs +93 -0
  31. package/library/actions/shared/perps/visible-state.mjs +3 -2
  32. package/library/manifests/extension.action-manifest.json +2 -1
  33. package/library/manifests/mobile.action-manifest.json +30 -5
  34. package/package.json +1 -1
  35. package/site/assets/help-recipes.json +1 -0
@@ -4,11 +4,13 @@ import { pathToFileURL } from 'node:url';
4
4
  import {
5
5
  MOBILE_BRIDGE_RESULT_ERROR_CODES,
6
6
  bridgeCommand,
7
+ createSetupBudget,
7
8
  deadlineFromTimeout,
8
9
  earliestDeadline,
9
10
  evalAsync,
10
11
  inheritedDeadline,
11
- isTargetTransition,
12
+ isUnansweredRead,
13
+ mobileNavigationTimeoutMs,
12
14
  navigate,
13
15
  routeSatisfiesNavigationTarget,
14
16
  runAdapter,
@@ -25,6 +27,7 @@ import {
25
27
  requireHouseNotional,
26
28
  requirePerpsOrderAllowed,
27
29
  } from '../../shared/perps/order-policy.mjs';
30
+ import { isRateLimited, rateLimitedError, withRateLimitRetry } from '../../shared/perps/rate-limit.mjs';
28
31
 
29
32
  function sleep(ms) {
30
33
  return new Promise((resolve) => setTimeout(resolve, ms));
@@ -169,7 +172,7 @@ async function readModeState(input, mode, deadline) {
169
172
  try {
170
173
  return await queryModeState(input, mode, deadline);
171
174
  } catch (error) {
172
- if (!isTargetTransition(error)) throw error;
175
+ if (!isUnansweredRead(error)) throw error;
173
176
  return null;
174
177
  }
175
178
  }
@@ -237,6 +240,8 @@ export async function ensureMode(input) {
237
240
  throw new Error(`Perps did not reach ${requestedMode} mode before timeout.`);
238
241
  }
239
242
 
243
+ // Retries the client-not-initialized race until the deadline, and a 429 with
244
+ // the shared rate-limit backoff.
240
245
  export async function retryPerpsClientRead(
241
246
  read,
242
247
  {
@@ -244,6 +249,8 @@ export async function retryPerpsClientRead(
244
249
  intervalMs = 500,
245
250
  sleep: wait = sleep,
246
251
  deadlineEpochMs,
252
+ call = 'PerpsController read',
253
+ random,
247
254
  } = {},
248
255
  ) {
249
256
  const localDeadline = Date.now() + Math.max(0, timeoutMs);
@@ -254,7 +261,7 @@ export async function retryPerpsClientRead(
254
261
  : Math.min(localDeadline, configuredDeadline);
255
262
  while (true) {
256
263
  try {
257
- return await read();
264
+ return await withRateLimitRetry(call, read, { deadline, sleep: wait, random });
258
265
  } catch (error) {
259
266
  if (!/CLIENT_NOT_INITIALIZED/u.test(String(error?.message ?? error))) {
260
267
  throw error;
@@ -386,7 +393,7 @@ async function readPositions(input) {
386
393
  }
387
394
  return positions;
388
395
  },
389
- { deadlineEpochMs: inheritedDeadline(input) },
396
+ { deadlineEpochMs: inheritedDeadline(input), call: 'PerpsController.getPositions' },
390
397
  );
391
398
  }
392
399
 
@@ -413,7 +420,7 @@ async function readOpenOrders(input) {
413
420
  }
414
421
  return orders;
415
422
  },
416
- { deadlineEpochMs: inheritedDeadline(input) },
423
+ { deadlineEpochMs: inheritedDeadline(input), call: 'PerpsController.getOpenOrders' },
417
424
  );
418
425
  }
419
426
 
@@ -445,7 +452,7 @@ async function readStrategyOrders(input) {
445
452
  }
446
453
  return orders;
447
454
  },
448
- { deadlineEpochMs: inheritedDeadline(input) },
455
+ { deadlineEpochMs: inheritedDeadline(input), call: 'PerpsController.getTwapOrders/getChaseOrders' },
449
456
  );
450
457
  }
451
458
 
@@ -903,7 +910,7 @@ async function readCurrentRoute(input) {
903
910
  try {
904
911
  return await bridgeCommand(input, ['get-route']);
905
912
  } catch (error) {
906
- if (!isTargetTransition(error)) throw error;
913
+ if (!isUnansweredRead(error)) throw error;
907
914
  return null;
908
915
  }
909
916
  }
@@ -961,8 +968,7 @@ async function waitForPerpsUiTarget(input, testId, deadline) {
961
968
  }
962
969
 
963
970
  async function selectPerpsMarketFromHome(input, symbol, previousRoute) {
964
- const timeoutMs = Number(input.node?.navigation_timeout_ms ?? 15_000);
965
- const deadline = deadlineFromTimeout(input, timeoutMs);
971
+ const deadline = deadlineFromTimeout(input, mobileNavigationTimeoutMs(input));
966
972
  const params = { market: { symbol } };
967
973
  const rowTestId = `perps-market-row-item-${symbol}`;
968
974
  const recentlyViewedTestId = `perps-recently-viewed-tile-${symbol}`;
@@ -1045,8 +1051,7 @@ async function waitForRouteChange(input, previousRoute, deadline) {
1045
1051
  }
1046
1052
 
1047
1053
  async function navigateFromMarketDetailsToHome(input, previousRoute) {
1048
- const timeoutMs = Number(input.node?.navigation_timeout_ms ?? 15_000);
1049
- const deadline = deadlineFromTimeout(input, timeoutMs);
1054
+ const deadline = deadlineFromTimeout(input, mobileNavigationTimeoutMs(input));
1050
1055
  let visibleTestIds = await readVisiblePerpsTestIds(input, deadline);
1051
1056
  let backTestId = PERPS_MARKET_DETAILS_BACK_TEST_IDS.find((testId) =>
1052
1057
  visibleTestIds.has(testId),
@@ -1468,6 +1473,16 @@ export async function placeOrder(input) {
1468
1473
  );
1469
1474
  }
1470
1475
  if (result?.success === false || result == null) {
1476
+ // Mobile keeps no placement receipt, so a 429 is never retried: nothing proves the venue saw no order.
1477
+ if (isRateLimited(result?.error)) {
1478
+ throw rateLimitedError({
1479
+ call: 'PerpsController.placeOrder',
1480
+ attempts: 1,
1481
+ waitedMs: 0,
1482
+ reason: `no retry, since nothing proves the ${symbol} order was not placed`,
1483
+ next: `read the ${symbol} positions and orders, then wait a minute and rerun only if nothing was placed`,
1484
+ });
1485
+ }
1471
1486
  throw new Error(`Failed to place ${symbol} ${side}: ${result?.error || JSON.stringify(result)}`);
1472
1487
  }
1473
1488
  if (orderType === 'limit') {
@@ -1732,9 +1747,20 @@ function mergeStateConfig(defaults, params) {
1732
1747
  };
1733
1748
  }
1734
1749
 
1735
- export function applyTradingStatePolicy(config) {
1750
+ // The policy owns positions, orders and strategies. An explicit request for one
1751
+ // of them would be dropped, so it fails before any phase runs.
1752
+ export function applyTradingStatePolicy(config, requested) {
1736
1753
  const policy = config.trading_state;
1737
1754
  if (policy === undefined) return config;
1755
+ const dropped = ['positions', 'orders', 'strategies'].filter(
1756
+ (key) => requested[key] !== undefined && requested[key] !== false,
1757
+ );
1758
+ if (dropped.length > 0) {
1759
+ throw new Error(
1760
+ `metamask.perps.start_state trading_state=${String(policy)} sets positions, orders and strategies itself, so the requested ${dropped.join(', ')} would be ignored. ` +
1761
+ `Omit trading_state to apply ${dropped.join(', ')}, or request ${dropped.join(', ')} in a separate metamask.perps.start_state node.`,
1762
+ );
1763
+ }
1738
1764
  if (policy === 'preserve') {
1739
1765
  return { ...config, positions: false, orders: false, strategies: false };
1740
1766
  }
@@ -1969,16 +1995,15 @@ export async function startState(input) {
1969
1995
  const params = paramsForState(input);
1970
1996
  const config = applyTradingStatePolicy(
1971
1997
  mergeStateConfig(profileDefaults(params.profile), params),
1998
+ params,
1972
1999
  );
1973
- const timeoutMs = Math.max(1, Number(input.node?.timeout_ms ?? 30_000));
1974
- const deadline = deadlineFromTimeout(input, timeoutMs);
1975
- const stateInput = () => {
1976
- const remainingMs = deadline - Date.now();
1977
- if (remainingMs <= 0) {
1978
- throw new Error(
1979
- 'metamask.perps.start_state timed out before completing all phases.',
1980
- );
1981
- }
2000
+ const budget = createSetupBudget(input, 'metamask.perps.start_state');
2001
+ const navigationSlice = { sliceMs: budget.navigationMs };
2002
+ // Product edits require an app restart before proof. Mobile relocks on restart,
2003
+ // so a deterministic Perps start state must restore the fixture-backed wallet
2004
+ // prerequisite before it navigates or converges controller state.
2005
+ const wallet = await budget.phase('unlock', (phaseInput) => {
2006
+ const remainingMs = phaseInput.node.timeout_ms;
1982
2007
  const configuredTargetMs = Number(input.node?.target_timeout_ms);
1983
2008
  const targetTimeoutMs = Math.min(
1984
2009
  remainingMs,
@@ -1993,75 +2018,78 @@ export async function startState(input) {
1993
2018
  ? configuredUnlockMs
1994
2019
  : remainingMs,
1995
2020
  );
1996
- return {
1997
- ...input,
2021
+ return ensureUnlocked({
2022
+ ...phaseInput,
2023
+ action: 'metamask.wallet.ensure_unlocked',
1998
2024
  node: {
1999
- ...input.node,
2000
- _action_deadline_epoch_ms: deadline,
2001
- timeout_ms: remainingMs,
2002
- bridge_timeout_ms: remainingMs,
2003
- cdp_timeout_ms: remainingMs,
2004
- controller_ready_timeout_ms: remainingMs,
2025
+ ...phaseInput.node,
2026
+ action: 'metamask.wallet.ensure_unlocked',
2005
2027
  target_timeout_ms: targetTimeoutMs,
2006
2028
  unlock_timeout_ms: unlockTimeoutMs,
2007
2029
  },
2008
- };
2009
- };
2010
- // Product edits require an app restart before proof. Mobile relocks on restart,
2011
- // so a deterministic Perps start state must restore the fixture-backed wallet
2012
- // prerequisite before it navigates or converges controller state.
2013
- const walletInput = stateInput();
2014
- const wallet = await ensureUnlocked({
2015
- ...walletInput,
2016
- action: 'metamask.wallet.ensure_unlocked',
2017
- node: { ...walletInput.node, action: 'metamask.wallet.ensure_unlocked' },
2030
+ });
2018
2031
  });
2019
- const accountInput = stateInput();
2020
2032
  const account = typeof config.account === 'string' && config.account.trim()
2021
- ? await selectAccount({
2022
- ...accountInput,
2033
+ ? await budget.phase('account', (phaseInput) => selectAccount({
2034
+ ...phaseInput,
2023
2035
  action: 'metamask.wallet.select_account',
2024
2036
  node: {
2025
- ...accountInput.node,
2037
+ ...phaseInput.node,
2026
2038
  action: 'metamask.wallet.select_account',
2027
2039
  ...(/^0x[0-9a-f]{40}$/iu.test(config.account.trim())
2028
2040
  ? { address: config.account.trim() }
2029
2041
  : { name: config.account.trim() }),
2030
2042
  },
2031
- })
2043
+ }))
2032
2044
  : { skipped: true };
2033
2045
  // Entering Perps initializes the provider client on a freshly launched app.
2034
2046
  // State reads before this navigation fail with CLIENT_NOT_INITIALIZED.
2035
- const navigation = await applyStateNavigation(stateInput(), config);
2036
- const provider = await ensureProvider(stateInput(), config);
2037
- const network = await ensureNetwork(stateInput(), config);
2047
+ const navigation = await budget.phase(
2048
+ 'navigation',
2049
+ (phaseInput) => applyStateNavigation(phaseInput, config),
2050
+ navigationSlice,
2051
+ );
2052
+ const provider = await budget.phase('provider', (phaseInput) =>
2053
+ ensureProvider(phaseInput, config));
2054
+ const network = await budget.phase('network', (phaseInput) =>
2055
+ ensureNetwork(phaseInput, config));
2038
2056
  let resolvedNavigation = provider.changed || network.changed
2039
- ? await applyStateNavigation(stateInput(), config)
2057
+ ? await budget.phase(
2058
+ 'navigation after provider or network change',
2059
+ (phaseInput) => applyStateNavigation(phaseInput, config),
2060
+ navigationSlice,
2061
+ )
2040
2062
  : navigation;
2041
- const modeInput = stateInput();
2042
2063
  const marketMode = config.market_mode
2043
- ? await ensureMode({
2044
- ...modeInput,
2064
+ ? await budget.phase('market mode', (phaseInput) => ensureMode({
2065
+ ...phaseInput,
2045
2066
  action: 'metamask.perps.ensure_mode',
2046
2067
  node: {
2047
- ...modeInput.node,
2068
+ ...phaseInput.node,
2048
2069
  action: 'metamask.perps.ensure_mode',
2049
2070
  mode: config.market_mode,
2050
2071
  },
2051
- })
2072
+ }))
2052
2073
  : { skipped: true };
2053
2074
  if (marketMode.alreadySelected === false) {
2054
- resolvedNavigation = await applyStateNavigation(stateInput(), config);
2055
- }
2056
- const tutorial = await applyTutorialState(stateInput(), config);
2057
- const readyToTrade = await assertReadyToTrade(stateInput(), config);
2058
- const balance = await assertBalance(stateInput(), config);
2059
- const strategies = await applyStrategiesState(
2060
- stateInput(),
2061
- config.strategies,
2062
- );
2063
- const orders = await applyOrdersState(stateInput(), config.orders);
2064
- const positions = await applyPositionsState(stateInput(), config.positions);
2075
+ resolvedNavigation = await budget.phase(
2076
+ 'navigation after mode change',
2077
+ (phaseInput) => applyStateNavigation(phaseInput, config),
2078
+ navigationSlice,
2079
+ );
2080
+ }
2081
+ const tutorial = await budget.phase('tutorial', (phaseInput) =>
2082
+ applyTutorialState(phaseInput, config));
2083
+ const readyToTrade = await budget.phase('ready to trade', (phaseInput) =>
2084
+ assertReadyToTrade(phaseInput, config));
2085
+ const balance = await budget.phase('balance', (phaseInput) =>
2086
+ assertBalance(phaseInput, config));
2087
+ const strategies = await budget.phase('strategies', (phaseInput) =>
2088
+ applyStrategiesState(phaseInput, config.strategies));
2089
+ const orders = await budget.phase('orders', (phaseInput) =>
2090
+ applyOrdersState(phaseInput, config.orders));
2091
+ const positions = await budget.phase('positions', (phaseInput) =>
2092
+ applyPositionsState(phaseInput, config.positions));
2065
2093
  return {
2066
2094
  action: input.action,
2067
2095
  profile: config.profile ?? params.profile ?? null,
@@ -2090,16 +2118,7 @@ export async function startState(input) {
2090
2118
  }
2091
2119
 
2092
2120
  export async function teardownState(input) {
2093
- const timeoutMs = Math.max(1, Number(input.node?.timeout_ms ?? 30_000));
2094
- const deadline = deadlineFromTimeout(input, timeoutMs);
2095
- const stateInput = (phase) => {
2096
- if (Date.now() >= deadline) {
2097
- throw new Error(
2098
- `metamask.perps.teardown_state timed out before ${phase}.`,
2099
- );
2100
- }
2101
- return withRemainingDeadline(input, deadline);
2102
- };
2121
+ const budget = createSetupBudget(input, 'metamask.perps.teardown_state');
2103
2122
  const params = paramsForState(input);
2104
2123
  const defaults = {
2105
2124
  provider: 'hyperliquid',
@@ -2110,28 +2129,23 @@ export async function teardownState(input) {
2110
2129
  strategies: params.market || params.symbol || params.markets || params.symbols ? { state: 'none', mode: 'matching' } : false,
2111
2130
  };
2112
2131
  const config = mergeStateConfig(defaults, params);
2113
- const environment = await assertRequestedRuntimeState(
2114
- stateInput('checking the requested environment'),
2115
- config,
2116
- );
2117
- const strategies = await applyStrategiesState(
2118
- stateInput('cleaning strategies'),
2119
- config.strategies,
2120
- );
2121
- const orders = await applyOrdersState(
2122
- stateInput('cleaning orders'),
2123
- config.orders,
2124
- );
2125
- const positions = await applyPositionsState(
2126
- stateInput('closing positions'),
2127
- config.positions,
2132
+ const environment = await budget.phase(
2133
+ 'checking the requested environment',
2134
+ (phaseInput) => assertRequestedRuntimeState(phaseInput, config),
2128
2135
  );
2136
+ const strategies = await budget.phase('cleaning strategies', (phaseInput) =>
2137
+ applyStrategiesState(phaseInput, config.strategies));
2138
+ const orders = await budget.phase('cleaning orders', (phaseInput) =>
2139
+ applyOrdersState(phaseInput, config.orders));
2140
+ const positions = await budget.phase('closing positions', (phaseInput) =>
2141
+ applyPositionsState(phaseInput, config.positions));
2129
2142
  const navigation =
2130
2143
  config.page === false
2131
2144
  ? { skipped: true }
2132
- : await applyStateNavigation(
2133
- stateInput('restoring the requested page'),
2134
- config,
2145
+ : await budget.phase(
2146
+ 'restoring the requested page',
2147
+ (phaseInput) => applyStateNavigation(phaseInput, config),
2148
+ { sliceMs: budget.navigationMs },
2135
2149
  );
2136
2150
  return {
2137
2151
  action: input.action,
@@ -29,6 +29,13 @@ const { resolvePort } = configModule;
29
29
  // second import path into the cjs bridge-runtime.
30
30
  export const MOBILE_BRIDGE_ERROR_CODES = BRIDGE_ERROR_CODES;
31
31
  export const MOBILE_BRIDGE_RESULT_ERROR_CODES = BRIDGE_RESULT_ERROR_CODES;
32
+ // Budget exhaustion, kept apart from the bridge's CDP_TIMEOUT: ACTION_DEADLINE
33
+ // is a bridge command its caller's deadline refused or cut short;
34
+ // DOMAIN_SETUP_DEADLINE is a setup phase that ran out of its slice.
35
+ export const MOBILE_BUDGET_ERROR_CODES = Object.freeze({
36
+ ACTION_DEADLINE: 'ACTION_DEADLINE',
37
+ DOMAIN_SETUP_DEADLINE: 'DOMAIN_SETUP_DEADLINE',
38
+ });
32
39
 
33
40
  const execFileAsync = promisify(execFile);
34
41
  const ACTION_DEADLINE_FIELD = '_action_deadline_epoch_ms';
@@ -256,7 +263,11 @@ export function resolveMobileTarget(input) {
256
263
  const iosSimulator = input.node?.simulator ?? input.node?.ios_simulator ?? contextEnv.IOS_SIMULATOR ?? process.env.IOS_SIMULATOR;
257
264
  const androidDevice = input.node?.android_device ?? contextEnv.ANDROID_DEVICE ?? process.env.ANDROID_DEVICE;
258
265
  const androidTargetDeviceName = input.node?.android_target_device_name ?? input.node?.androidTargetDeviceName ?? contextEnv.ANDROID_TARGET_DEVICE_NAME ?? process.env.ANDROID_TARGET_DEVICE_NAME;
259
- const adbSerial = input.node?.adb_serial ?? contextEnv.ADB_SERIAL ?? contextEnv.ANDROID_SERIAL ?? process.env.ADB_SERIAL ?? process.env.ANDROID_SERIAL ?? androidDevice;
266
+ const explicitAdbSerial = input.node?.adb_serial ?? contextEnv.ADB_SERIAL ?? contextEnv.ANDROID_SERIAL ?? process.env.ADB_SERIAL ?? process.env.ANDROID_SERIAL;
267
+ const adbSerial = explicitAdbSerial ?? androidDevice;
268
+ // Only an explicit adb serial says which device class answers: ANDROID_DEVICE
269
+ // alone can be an AVD or Metro device name.
270
+ const physicalAndroid = Boolean(explicitAdbSerial) && !isAndroidEmulatorSerial(explicitAdbSerial);
260
271
  if (requestedPlatform === 'ios') {
261
272
  return {
262
273
  watcherPort,
@@ -264,6 +275,7 @@ export function resolveMobileTarget(input) {
264
275
  androidDevice: undefined,
265
276
  androidTargetDeviceName: undefined,
266
277
  adbSerial: undefined,
278
+ physicalAndroid: false,
267
279
  requestedPlatform,
268
280
  };
269
281
  }
@@ -274,6 +286,7 @@ export function resolveMobileTarget(input) {
274
286
  androidDevice,
275
287
  androidTargetDeviceName,
276
288
  adbSerial,
289
+ physicalAndroid,
277
290
  requestedPlatform,
278
291
  };
279
292
  }
@@ -283,10 +296,15 @@ export function resolveMobileTarget(input) {
283
296
  androidDevice,
284
297
  androidTargetDeviceName,
285
298
  adbSerial,
299
+ physicalAndroid,
286
300
  requestedPlatform,
287
301
  };
288
302
  }
289
303
 
304
+ export function isAndroidEmulatorSerial(serial) {
305
+ return String(serial ?? '').startsWith('emulator-');
306
+ }
307
+
290
308
  export async function openExternalDeeplink(input, url, timeout = 15000) {
291
309
  const target = resolveMobileTarget(input);
292
310
  const requestedPlatform = String(
@@ -470,17 +488,17 @@ export async function bridgeCommand(input, args) {
470
488
  ? actionDeadline - Date.now()
471
489
  : configuredTimeoutMs;
472
490
  if (remainingActionMs <= 0) {
473
- throw coded(
474
- new Error(
475
- `Mobile CDP bridge command did not start before its action deadline: ${String(args[0] ?? 'unknown')}`,
476
- ),
477
- BRIDGE_ERROR_CODES.CDP_TIMEOUT,
491
+ const error = new Error(
492
+ `ACTION_DEADLINE: bridge command ${String(args[0] ?? 'unknown')} was not started; its action deadline passed ${Math.round(-remainingActionMs)}ms ago (per-command limit ${configuredTimeoutMs}ms).`,
478
493
  );
494
+ error.deadlineEpochMs = actionDeadline;
495
+ throw coded(error, MOBILE_BUDGET_ERROR_CODES.ACTION_DEADLINE);
479
496
  }
480
497
  const timeoutMs = Math.max(
481
498
  1,
482
499
  Math.min(configuredTimeoutMs, remainingActionMs),
483
500
  );
501
+ const cutByActionDeadline = timeoutMs < configuredTimeoutMs;
484
502
  const result = await new Promise((resolve, reject) => {
485
503
  const child = spawn(process.execPath, [script, ...args], {
486
504
  cwd: input.context.projectRoot,
@@ -521,8 +539,20 @@ export async function bridgeCommand(input, args) {
521
539
  });
522
540
  if (result.timedOut) {
523
541
  const command = ['node', path.relative(input.context.projectRoot, script), ...redactBridgeArgs(args)].join(' ');
542
+ // A timer the action deadline shortened means the caller's budget ran out,
543
+ // not the bridge: say so, and keep it out of the target-transition codes
544
+ // so route waits stop instead of retrying refused commands.
545
+ if (cutByActionDeadline) {
546
+ const error = new Error(
547
+ `ACTION_DEADLINE: bridge command ${String(args[0] ?? 'unknown')} was stopped after ${result.timeoutMs}ms when its action deadline ended (per-command limit ${configuredTimeoutMs}ms): ${command}`,
548
+ );
549
+ error.deadlineEpochMs = actionDeadline;
550
+ throw coded(error, MOBILE_BUDGET_ERROR_CODES.ACTION_DEADLINE);
551
+ }
524
552
  throw coded(
525
- new Error(`Mobile CDP bridge command timed out after ${result.timeoutMs}ms: ${command}`),
553
+ new Error(
554
+ `Mobile CDP bridge command timed out after ${result.timeoutMs}ms (CDP_COMMAND_TIMEOUT: per-command limit ${configuredTimeoutMs}ms${Number.isFinite(actionDeadline) ? `, action budget had ${Math.max(0, Math.round(actionDeadline - Date.now()))}ms left` : ''}): ${command}`,
555
+ ),
526
556
  BRIDGE_ERROR_CODES.CDP_TIMEOUT,
527
557
  );
528
558
  }
@@ -628,6 +658,13 @@ export function isTargetTransition(error) {
628
658
  return TARGET_TRANSITION_CODES.has(error?.code);
629
659
  }
630
660
 
661
+ // A read a target transition interrupted, or the action deadline cut short,
662
+ // has no answer yet. Polling callers treat it as "not yet" so their own loop
663
+ // ends at the deadline with its own timeout message.
664
+ export function isUnansweredRead(error) {
665
+ return isTargetTransition(error) || error?.code === MOBILE_BUDGET_ERROR_CODES.ACTION_DEADLINE;
666
+ }
667
+
631
668
  function valueContains(actual, expected) {
632
669
  if (Array.isArray(expected)) {
633
670
  return Array.isArray(actual) &&
@@ -675,19 +712,140 @@ export function earliestDeadline(input, deadline) {
675
712
 
676
713
  // The earlier of now + timeoutMs and the calling action's deadline. Route waits
677
714
  // use it because past the action deadline bridgeCommand refuses to start
678
- // get-route (CDP_TIMEOUT): polling on would only retry that refusal and could
679
- // never see a route that did arrive.
715
+ // get-route (ACTION_DEADLINE): polling on would only retry that refusal and
716
+ // could never see a route that did arrive.
680
717
  export function deadlineFromTimeout(input, timeoutMs) {
681
718
  return earliestDeadline(input, Date.now() + Math.max(0, Number(timeoutMs)));
682
719
  }
683
720
 
721
+ // Default budgets by device class. A physical Android device over USB answers
722
+ // bridge calls in 0.1-0.5 s when idle and slower while the app is busy; a Perps
723
+ // market-details -> home navigation on a Pixel 6a took ~18 s. Emulators and
724
+ // simulators keep the shorter budgets so a real hang there still fails fast.
725
+ const MOBILE_BUDGET_DEFAULTS = Object.freeze({
726
+ virtual: { setupMs: 30_000, navigationMs: 15_000 },
727
+ physical: { setupMs: 60_000, navigationMs: 30_000 },
728
+ });
729
+ // Per bridge command inside a domain setup, so one hung command cannot spend
730
+ // the whole setup budget. Recipes override it with cdp_timeout_ms.
731
+ export const SETUP_COMMAND_TIMEOUT_MS = 15_000;
732
+
733
+ function positiveMs(value) {
734
+ const ms = Number(value);
735
+ return value !== null && value !== '' && Number.isFinite(ms) && ms > 0 ? ms : null;
736
+ }
737
+
738
+ // Env overrides (MM_HARNESS_MOBILE_SETUP_TIMEOUT_MS,
739
+ // MM_HARNESS_MOBILE_NAVIGATION_TIMEOUT_MS) replace the device-class defaults;
740
+ // a node's own timeout_ms / navigation_timeout_ms still wins over both.
741
+ export function mobileBudgetDefaults(input) {
742
+ const env = { ...process.env, ...(input.context?.env || {}) };
743
+ const deviceClass = resolveMobileTarget(input).physicalAndroid ? 'physical' : 'virtual';
744
+ const defaults = MOBILE_BUDGET_DEFAULTS[deviceClass];
745
+ return {
746
+ deviceClass,
747
+ setupMs: positiveMs(env.MM_HARNESS_MOBILE_SETUP_TIMEOUT_MS) ?? defaults.setupMs,
748
+ navigationMs:
749
+ positiveMs(env.MM_HARNESS_MOBILE_NAVIGATION_TIMEOUT_MS) ?? defaults.navigationMs,
750
+ };
751
+ }
752
+
753
+ export function mobileNavigationTimeoutMs(input) {
754
+ return positiveMs(input.node?.navigation_timeout_ms) ?? mobileBudgetDefaults(input).navigationMs;
755
+ }
756
+
757
+ export function mobileSetupTimeoutMs(input) {
758
+ return positiveMs(input.node?.timeout_ms) ?? mobileBudgetDefaults(input).setupMs;
759
+ }
760
+
761
+ // One overall budget for a multi-step domain setup (unlock, navigation, state
762
+ // convergence). Each phase runs on its own deadline: the rest of the setup
763
+ // budget, or less when sliceMs bounds it. Each bridge command inside a phase
764
+ // gets the per-command limit, capped by that phase deadline. A phase that runs
765
+ // out of time fails as DOMAIN_SETUP_DEADLINE naming the phase and both limits,
766
+ // not as a CDP timeout of whichever command happened to be running.
767
+ export function createSetupBudget(input, action) {
768
+ const { deviceClass } = mobileBudgetDefaults(input);
769
+ const navigationMs = mobileNavigationTimeoutMs(input);
770
+ const commandMs =
771
+ positiveMs(input.node?.bridge_timeout_ms ?? input.node?.cdp_timeout_ms) ??
772
+ SETUP_COMMAND_TIMEOUT_MS;
773
+ const startedAt = Date.now();
774
+ const deadline = deadlineFromTimeout(input, mobileSetupTimeoutMs(input));
775
+ const limitMs = deadline - startedAt;
776
+
777
+ function deadlineError(phase, phaseStartedAt, phaseLimitMs, sliced, cause) {
778
+ const now = Date.now();
779
+ const budget = {
780
+ action,
781
+ phase,
782
+ deviceClass,
783
+ phaseElapsedMs: now - phaseStartedAt,
784
+ phaseLimitMs,
785
+ setupElapsedMs: now - startedAt,
786
+ setupLimitMs: limitMs,
787
+ };
788
+ const phaseText = cause === null
789
+ ? `phase '${phase}' could not start`
790
+ : `phase '${phase}' ran ${budget.phaseElapsedMs}ms of its ${phaseLimitMs}ms ${sliced ? 'slice' : 'share of the setup budget'}`;
791
+ const error = new Error(
792
+ `DOMAIN_SETUP_DEADLINE: ${action} ${phaseText}; setup ran ${budget.setupElapsedMs}ms of ${limitMs}ms (${deviceClass} device). ` +
793
+ `Raise timeout_ms (setup) or navigation_timeout_ms (navigation slice) on the node, or MM_HARNESS_MOBILE_SETUP_TIMEOUT_MS / MM_HARNESS_MOBILE_NAVIGATION_TIMEOUT_MS.` +
794
+ (cause === null ? '' : `\n Cause: ${String(cause?.message ?? cause)}`),
795
+ cause === null ? undefined : { cause },
796
+ );
797
+ error.budget = budget;
798
+ return coded(error, MOBILE_BUDGET_ERROR_CODES.DOMAIN_SETUP_DEADLINE);
799
+ }
800
+
801
+ return {
802
+ deadline,
803
+ limitMs,
804
+ navigationMs,
805
+ async phase(name, run, { sliceMs } = {}) {
806
+ const phaseStartedAt = Date.now();
807
+ const sliced = sliceMs !== undefined && phaseStartedAt + sliceMs < deadline;
808
+ const phaseDeadline = sliced ? phaseStartedAt + sliceMs : deadline;
809
+ const phaseLimitMs = phaseDeadline - phaseStartedAt;
810
+ if (phaseLimitMs <= 0) {
811
+ throw deadlineError(name, phaseStartedAt, phaseLimitMs, false, null);
812
+ }
813
+ const commandLimitMs = Math.min(commandMs, phaseLimitMs);
814
+ const phaseInput = {
815
+ ...input,
816
+ node: {
817
+ ...input.node,
818
+ [ACTION_DEADLINE_FIELD]: phaseDeadline,
819
+ timeout_ms: phaseLimitMs,
820
+ bridge_timeout_ms: commandLimitMs,
821
+ cdp_timeout_ms: commandLimitMs,
822
+ controller_ready_timeout_ms: phaseLimitMs,
823
+ navigation_timeout_ms: Math.min(navigationMs, phaseLimitMs),
824
+ },
825
+ };
826
+ try {
827
+ return await run(phaseInput);
828
+ } catch (error) {
829
+ if (error?.code === MOBILE_BUDGET_ERROR_CODES.DOMAIN_SETUP_DEADLINE) throw error;
830
+ // A nested wait with its own shorter deadline is not this phase running out.
831
+ const phaseRanOut =
832
+ Date.now() >= phaseDeadline ||
833
+ (error?.code === MOBILE_BUDGET_ERROR_CODES.ACTION_DEADLINE &&
834
+ error.deadlineEpochMs >= phaseDeadline);
835
+ if (!phaseRanOut) throw error;
836
+ throw deadlineError(name, phaseStartedAt, phaseLimitMs, sliced, error);
837
+ }
838
+ },
839
+ };
840
+ }
841
+
684
842
  async function waitForNextRouteProbe(deadline) {
685
843
  const remaining = deadline - Date.now();
686
844
  if (remaining > 0) await sleep(Math.min(250, remaining));
687
845
  }
688
846
 
689
847
  export async function navigate(input, route, params = {}, expectedRoute) {
690
- const timeoutMs = Number(input.node?.navigation_timeout_ms ?? 15000);
848
+ const timeoutMs = mobileNavigationTimeoutMs(input);
691
849
  let requestedRoute = String(expectedRoute ?? route);
692
850
  let requestedParams = requestedRoute === String(route) ? params : undefined;
693
851
  const nestedTarget = expectedRoute == null && typeof params?.screen === 'string';
@@ -850,6 +1008,9 @@ export async function waitForRoute(
850
1008
  try {
851
1009
  lastRoute = await bridgeCommand(input, ['get-route']);
852
1010
  } catch (error) {
1011
+ // The action deadline cut the last poll short: report the route wait
1012
+ // (expected and last route) rather than that one poll.
1013
+ if (error?.code === MOBILE_BUDGET_ERROR_CODES.ACTION_DEADLINE) break;
853
1014
  if (!isTargetTransition(error)) throw error;
854
1015
  lastTransitionError = error;
855
1016
  transitionCodes.push(error.code);
@@ -1,5 +1,10 @@
1
1
  // Open wallet home through WalletView, with a verified HomeNav fallback for older navigators.
2
- import { bridgeCommand, navigate, waitForRoute } from '../platform/bridge.mjs';
2
+ import {
3
+ bridgeCommand,
4
+ mobileNavigationTimeoutMs,
5
+ navigate,
6
+ waitForRoute,
7
+ } from '../platform/bridge.mjs';
3
8
 
4
9
  /**
5
10
  * Navigate to the wallet home across MetaMask Mobile navigation generations.
@@ -19,7 +24,7 @@ export async function navigateWalletHome(input) {
19
24
  const currentRoute = await waitForRoute(
20
25
  input,
21
26
  'WalletView',
22
- Number(input.node?.navigation_timeout_ms ?? 15000),
27
+ mobileNavigationTimeoutMs(input),
23
28
  );
24
29
  return {
25
30
  navigation: {
@@ -5,14 +5,18 @@ import { pathToFileURL } from 'node:url';
5
5
  import {
6
6
  bridgeCommand,
7
7
  MOBILE_BRIDGE_ERROR_CODES,
8
+ MOBILE_BUDGET_ERROR_CODES,
8
9
  runAdapter,
9
10
  } from '../platform/bridge.mjs';
10
11
 
11
12
  const sleep = (ms) => new Promise((resolve) => setTimeout(resolve, ms));
13
+ // A switch-account write cut short by the deadline may still have landed, so
14
+ // ACTION_DEADLINE leaves the outcome as unknown as a transport failure does.
12
15
  const TRANSIENT_READ_CODES = new Set([
13
16
  MOBILE_BRIDGE_ERROR_CODES.NO_TARGET,
14
17
  MOBILE_BRIDGE_ERROR_CODES.CDP_TIMEOUT,
15
18
  MOBILE_BRIDGE_ERROR_CODES.WS_CLOSED,
19
+ MOBILE_BUDGET_ERROR_CODES.ACTION_DEADLINE,
16
20
  ]);
17
21
  const READ_ATTEMPT_TIMEOUT_MS = 10_000;
18
22
 
@@ -0,0 +1,8 @@
1
+ // The epoch ms by which a live adapter action must finish. The runner kills a
2
+ // live adapter process liveAdapterProcessTimeoutMs(node) after starting it.
3
+
4
+ import { liveAdapterProcessTimeoutMs } from '@farmslot/recipe-cli/harness';
5
+
6
+ export function liveAdapterDeadline(node, reserveMs = 5_000) {
7
+ return performance.timeOrigin + liveAdapterProcessTimeoutMs(node ?? {}) - reserveMs;
8
+ }