@deeeed/metamask-harness 0.86.2 → 0.88.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 (61) hide show
  1. package/CHANGELOG.md +49 -0
  2. package/adapters/extension/ensure-browser.sh +6 -10
  3. package/adapters/extension/inject.mjs +1 -0
  4. package/adapters/extension/launch-browser.cjs +31 -4
  5. package/adapters/extension/profile-extension-state.cjs +156 -0
  6. package/adapters/extension/readiness.mjs +21 -2
  7. package/adapters/manifest.json +8 -0
  8. package/adapters/mobile/wait-for-bridge.cjs +9 -3
  9. package/dist/adapters/extension/actions.js +2 -1
  10. package/dist/adapters/extension/run.js +6 -0
  11. package/dist/adapters/mobile/action-inputs.js +20 -0
  12. package/dist/adapters/mobile/actions.js +18 -1
  13. package/dist/adapters/mobile/android-screenrecord.js +274 -0
  14. package/dist/adapters/mobile/readiness.js +2 -1
  15. package/dist/adapters/mobile/recording-outcome.js +54 -0
  16. package/dist/adapters/mobile/recording-target.js +11 -0
  17. package/dist/adapters/mobile/run.js +58 -15
  18. package/dist/adapters/mobile/ui.js +96 -2
  19. package/dist/adapters/perps-mainnet-cleanup.js +122 -0
  20. package/dist/adapters/perps-order-inputs.js +43 -28
  21. package/dist/adapters.js +4 -1
  22. package/dist/commands/doctor.js +33 -14
  23. package/dist/commands/status-probe.js +2 -1
  24. package/dist/engine.js +11 -2
  25. package/dist/funding-execution-context.js +13 -8
  26. package/dist/host.js +6 -2
  27. package/dist/index.js +3 -0
  28. package/dist/mm-harness-cli.js +4 -2
  29. package/dist/provider.js +72 -0
  30. package/dist/recipe-security.js +1 -0
  31. package/docs/CONTRIBUTING.md +7 -0
  32. package/docs/RECIPES.md +96 -9
  33. package/docs/RELEASE-QA-CAPABILITY-MAP.md +5 -1
  34. package/docs/cli-contract.md +1 -1
  35. package/library/actions/core/perps/read_account.mjs +4 -2
  36. package/library/actions/core/perps/read_orders.mjs +4 -2
  37. package/library/actions/core/perps/read_positions.mjs +4 -2
  38. package/library/actions/extension/analytics/consent.mjs +7 -2
  39. package/library/actions/extension/perps/compare_provider_market.mjs +12 -8
  40. package/library/actions/extension/perps/mutation-receipt.mjs +124 -26
  41. package/library/actions/extension/perps/perps.mjs +298 -39
  42. package/library/actions/extension/perps/read_snapshot.mjs +6 -2
  43. package/library/actions/extension/perps/set_network.mjs +6 -0
  44. package/library/actions/extension/platform/cdp.mjs +6 -2
  45. package/library/actions/extension/ui/navigate.mjs +8 -0
  46. package/library/actions/mobile/perps/perps.mjs +264 -102
  47. package/library/actions/mobile/perps/place_order.mjs +7 -4
  48. package/library/actions/mobile/platform/bridge.mjs +171 -10
  49. package/library/actions/mobile/wallet/home.mjs +7 -2
  50. package/library/actions/mobile/wallet/select_account.mjs +4 -0
  51. package/library/actions/shared/app/live-adapter-deadline.mjs +8 -0
  52. package/library/actions/shared/perps/mainnet-order-ledger.mjs +378 -0
  53. package/library/actions/shared/perps/order-policy.mjs +56 -19
  54. package/library/actions/shared/perps/rate-limit.mjs +93 -0
  55. package/library/actions/shared/perps/visible-state.mjs +3 -2
  56. package/library/actions/shared/wallet/fixture-accounts.mjs +9 -3
  57. package/library/manifests/extension.action-manifest.json +36 -5
  58. package/library/manifests/mobile.action-manifest.json +48 -10
  59. package/package.json +14 -9
  60. package/scripts/validate-release-report.mjs +14 -2
  61. package/site/assets/help-recipes.json +2 -1
@@ -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
+ }