@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
@@ -0,0 +1,93 @@
1
+ // Classify a Hyperliquid HTTP 429 (Too Many Requests) as RATE_LIMITED and retry
2
+ // Perps reads a bounded number of times with exponential backoff and jitter.
3
+ // Farm runs share one testnet account and egress IP, so a 429 is load on the
4
+ // venue, not a product result. Placements never retry here: see
5
+ // runGuardedExternalMutation, which retries only once the venue proves nothing
6
+ // was placed.
7
+
8
+ export const RATE_LIMITED = 'RATE_LIMITED';
9
+ export const RATE_LIMIT_RETRIES = 3;
10
+ const BASE_BACKOFF_MS = 1_000;
11
+
12
+ const TOO_MANY_REQUESTS = /too many requests|\b(?:http|status(?:\s*code)?)\s*:?\s*429\b/iu;
13
+
14
+ export function isRateLimited(error) {
15
+ if (error?.code === RATE_LIMITED) return true;
16
+ return TOO_MANY_REQUESTS.test(error instanceof Error ? error.message : String(error ?? ''));
17
+ }
18
+
19
+ // Retry n (1-based) waits between half and all of 1 s, 2 s, 4 s: at most 7 s over
20
+ // the three retries.
21
+ export function rateLimitBackoffMs(retry, random = Math.random) {
22
+ const ceiling = BASE_BACKOFF_MS * 2 ** (retry - 1);
23
+ return Math.round(ceiling / 2 + random() * (ceiling / 2));
24
+ }
25
+
26
+ // The sentence naming the call, its attempts and the backoff it used.
27
+ export function describeRateLimit({ call, attempts, waitedMs }) {
28
+ return `Hyperliquid answered 429 Too Many Requests to ${call} on ${attempts} attempt${attempts === 1 ? '' : 's'} `
29
+ + `after ${waitedMs} ms of backoff (exponential from ${BASE_BACKOFF_MS} ms with jitter, at most ${RATE_LIMIT_RETRIES} retries)`;
30
+ }
31
+
32
+ export function rateLimitedError({ call, attempts, waitedMs, reason = null, next = 'wait a minute and rerun' }) {
33
+ return Object.assign(
34
+ new Error(`${RATE_LIMITED}: ${describeRateLimit({ call, attempts, waitedMs })}${reason ? `; ${reason}` : ''}. Next: ${next}.`),
35
+ { code: RATE_LIMITED, call, attempts, waitedMs },
36
+ );
37
+ }
38
+
39
+ export function backoffSleep(ms) {
40
+ return new Promise((resolve) => setTimeout(resolve, ms));
41
+ }
42
+
43
+ // A retry that has not answered by the deadline fails there; the read itself
44
+ // keeps running, but the action no longer waits for it.
45
+ async function beforeDeadline(promise, deadline, timedOut) {
46
+ let timer;
47
+ try {
48
+ return await Promise.race([
49
+ promise,
50
+ new Promise((_, reject) => {
51
+ timer = setTimeout(() => reject(timedOut()), Math.max(0, deadline - Date.now()));
52
+ }),
53
+ ]);
54
+ } finally {
55
+ clearTimeout(timer);
56
+ }
57
+ }
58
+
59
+ /**
60
+ * Run a read, retrying it after a 429 up to RATE_LIMIT_RETRIES times.
61
+ * @param call - The request named in the error, e.g. perpsGetPositions.
62
+ * @param read - The read to run; any other error is rethrown at once.
63
+ * @param options.deadline - Epoch ms the action must finish by. A retry starts only if its backoff
64
+ * and another attempt as long as the last one end before it, and fails RATE_LIMITED at it.
65
+ * @returns The read's result, or throws RATE_LIMITED once the retries or the deadline are spent.
66
+ */
67
+ export async function withRateLimitRetry(call, read, { deadline = null, sleep = backoffSleep, random = Math.random } = {}) {
68
+ let waitedMs = 0;
69
+ for (let attempt = 1; ; attempt += 1) {
70
+ const startedAt = Date.now();
71
+ try {
72
+ if (attempt === 1 || !Number.isFinite(deadline)) return await read();
73
+ return await beforeDeadline(read(), deadline, () => rateLimitedError({
74
+ call, attempts: attempt, waitedMs, reason: 'the last retry did not answer before the action deadline',
75
+ }));
76
+ } catch (error) {
77
+ // A nested read already spent its retries.
78
+ if (error?.code === RATE_LIMITED || !isRateLimited(error)) throw error;
79
+ if (attempt > RATE_LIMIT_RETRIES) {
80
+ throw rateLimitedError({ call, attempts: attempt, waitedMs });
81
+ }
82
+ const backoffMs = rateLimitBackoffMs(attempt, random);
83
+ if (Number.isFinite(deadline) && Date.now() + backoffMs + (Date.now() - startedAt) >= deadline) {
84
+ throw rateLimitedError({
85
+ call, attempts: attempt, waitedMs,
86
+ reason: `the next ${backoffMs} ms backoff and another attempt would pass the action deadline`,
87
+ });
88
+ }
89
+ await sleep(backoffMs);
90
+ waitedMs += backoffMs;
91
+ }
92
+ }
93
+ }
@@ -191,7 +191,7 @@ function visibleFeatures(items) {
191
191
  const long = has(/(?:long-cta-button|direction-long|market-details-long-button)$/u);
192
192
  const short = has(/(?:short-cta-button|direction-short|market-details-short-button)$/u);
193
193
  return {
194
- activity: has(/^(?:transaction-card-|perps-(?:recent-activity|activity))/u),
194
+ activity: has(/^(?:transaction-card-|perps-(?:recent-activity|activity|market-trades-row-))/u),
195
195
  balance: has(/^(?:perps-balance-dropdown|perps-balance-actions-available-value|perps-market-(?:balance-value|available-balance-text))/u),
196
196
  chart: has(/^(?:chart_surface|tv_chart_container)$/u) ||
197
197
  has(/perps-(?:candlestick-chart|market-detail-chart|market-details-fullscreen-chart|pro-market-chart)/u),
@@ -283,7 +283,8 @@ export function normalizeVisiblePerpsState(raw, options, platform) {
283
283
  options.maxItems,
284
284
  ),
285
285
  activity: compactEntries(
286
- selected(unique, (item) => /^(?:transaction-card-|transaction-item(?:-|$)|perps-activity-row-)/u.test(item.testId), options.maxItems),
286
+ // Mobile previews: Perps home Recent activity and the market's recent trades.
287
+ selected(unique, (item) => /^(?:transaction-card-|transaction-item(?:-|$)|perps-activity-row-|perps-(?:recent-activity|market-trades)-row-\d+$)/u.test(item.testId), options.maxItems),
287
288
  options.maxItems,
288
289
  ),
289
290
  orderEntry: raw.orderEntry && typeof raw.orderEntry === 'object' ? {
@@ -3788,7 +3788,7 @@
3788
3788
  ]
3789
3789
  },
3790
3790
  "metamask.perps.read_snapshot": {
3791
- "description": "Read a bounded, redacted source-dev snapshot of Extension Perps state through the optional product background hook.",
3791
+ "description": "Read a bounded, redacted source-dev snapshot of Extension Perps state through the optional product background hook. The history section is the funds ledger from perpsGetUserHistory (deposits, withdrawals and transfers): it holds no trades or orders, so zero history items does not mean no trading history. Read trade and order rows with metamask.perps.read_visible_state activity.",
3792
3792
  "schema": {
3793
3793
  "type": "object",
3794
3794
  "properties": {
@@ -3830,6 +3830,7 @@
3830
3830
  },
3831
3831
  "history": {
3832
3832
  "type": "object",
3833
+ "description": "Funds-ledger window (deposits, withdrawals and transfers) passed to perpsGetUserHistory; trades and orders are not part of it.",
3833
3834
  "properties": {
3834
3835
  "start_time_ms": {
3835
3836
  "type": "integer",
@@ -930,7 +930,7 @@
930
930
  ]
931
931
  },
932
932
  "ui.wait_for": {
933
- "description": "Wait for a React Native testID/test_id or text target. expected=visible requires a measured viewport intersection; expected=present only requires the mounted tree.",
933
+ "description": "Wait for a React Native testID/test_id or text target. expected=visible requires a measured viewport intersection; expected=present only requires the mounted tree. expected=absent also passes when every mounted match is on a screen the navigator hides (an inactive tab, or a non-modal stack screen under a non-modal screen) and a native observation does not show the test ID.",
934
934
  "schema": {
935
935
  "type": "object",
936
936
  "properties": {
@@ -960,7 +960,8 @@
960
960
  "type": "boolean"
961
961
  },
962
962
  "timeout_ms": {
963
- "type": "number"
963
+ "type": "number",
964
+ "description": "Total wait in milliseconds (default 10000). The hidden-screen check for expected=absent needs about 3 s of it left for its native read."
964
965
  }
965
966
  },
966
967
  "additionalProperties": false
@@ -3544,7 +3545,7 @@
3544
3545
  "trading_state": {
3545
3546
  "type": "string",
3546
3547
  "enum": ["preserve", "clean_selected_testnet_market"],
3547
- "description": "High-level state policy. The clean policy removes matching positions, ordinary orders, TWAPs, and Chase sessions only on testnet and preserves them on mainnet."
3548
+ "description": "High-level state policy that sets positions, orders and strategies itself: preserve leaves them untouched, and the clean policy removes matching positions, ordinary orders, TWAPs, and Chase sessions only on testnet and preserves them on mainnet. Cannot be combined with an explicit positions, orders or strategies request other than false; omit it to apply such a request."
3548
3549
  },
3549
3550
  "positions": {
3550
3551
  "description": "Requested position state. Omit it or pass false to preserve current positions.",
@@ -3768,7 +3769,19 @@
3768
3769
  "additionalProperties": false
3769
3770
  },
3770
3771
  "timeout_ms": {
3771
- "type": "number"
3772
+ "type": "number",
3773
+ "minimum": 1,
3774
+ "description": "Overall budget for the whole start state setup: every phase runs inside it. Default 30000 on emulators and simulators, 60000 on a physical Android device (explicit non-emulator adb serial), or MM_HARNESS_MOBILE_SETUP_TIMEOUT_MS. A phase that runs out fails as DOMAIN_SETUP_DEADLINE naming the phase."
3775
+ },
3776
+ "navigation_timeout_ms": {
3777
+ "type": "number",
3778
+ "minimum": 1,
3779
+ "description": "Slice of the setup budget for each navigation phase. Default 15000 on emulators and simulators, 30000 on a physical Android device, or MM_HARNESS_MOBILE_NAVIGATION_TIMEOUT_MS."
3780
+ },
3781
+ "cdp_timeout_ms": {
3782
+ "type": "number",
3783
+ "minimum": 1,
3784
+ "description": "Limit for each bridge command inside the setup, capped by the phase deadline. Default 15000. A command that hits it fails as CDP_TIMEOUT (CDP_COMMAND_TIMEOUT in the message)."
3772
3785
  },
3773
3786
  "side": {
3774
3787
  "type": "string",
@@ -4104,7 +4117,19 @@
4104
4117
  "additionalProperties": false
4105
4118
  },
4106
4119
  "timeout_ms": {
4107
- "type": "number"
4120
+ "type": "number",
4121
+ "minimum": 1,
4122
+ "description": "Overall budget for the whole teardown: every phase runs inside it. Default 30000 on emulators and simulators, 60000 on a physical Android device (explicit non-emulator adb serial), or MM_HARNESS_MOBILE_SETUP_TIMEOUT_MS. A phase that runs out fails as DOMAIN_SETUP_DEADLINE naming the phase."
4123
+ },
4124
+ "navigation_timeout_ms": {
4125
+ "type": "number",
4126
+ "minimum": 1,
4127
+ "description": "Slice of the setup budget for each navigation phase. Default 15000 on emulators and simulators, 30000 on a physical Android device, or MM_HARNESS_MOBILE_NAVIGATION_TIMEOUT_MS."
4128
+ },
4129
+ "cdp_timeout_ms": {
4130
+ "type": "number",
4131
+ "minimum": 1,
4132
+ "description": "Limit for each bridge command inside the setup, capped by the phase deadline. Default 15000. A command that hits it fails as CDP_TIMEOUT (CDP_COMMAND_TIMEOUT in the message)."
4108
4133
  },
4109
4134
  "side": {
4110
4135
  "type": "string",
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@deeeed/metamask-harness",
3
- "version": "0.86.2",
3
+ "version": "0.87.0",
4
4
  "type": "module",
5
5
  "bin": {
6
6
  "mm-harness": "bin/mm-harness"
@@ -202,6 +202,7 @@
202
202
  "Flake risk: wait on observable state instead of sleeping, keep device and runtime identity explicit, and never overwrite a prior run's artifacts.",
203
203
  "Mutation: require an independent state assertion and identity-bound receipt. Bind activity to that operation's accepted ID; an old row or nonzero count is insufficient. Opening and closing orders have different IDs. Schedule authorized cleanup in teardown, verify its result, and report any residue. Testnet cleanup closes what the task opened and never silently undoes another task's live proof on a reserved account or market.",
204
204
  "Testnet account state: on a testnet account or market the task owns for its run (the slot's fixture account, or one reserved with exclusive_testnet_market=true), converging is an explicit, ownership-scoped setup choice, not a violation. Use the team library's perps.ensure-market-state or perps.clean-market-testnet, or on Mobile and Core metamask.perps.ensure_positions / metamask.perps.ensure_orders (mode=all for strays), record what was closed in the trace, then prove. A pre-existing position or order on an account the task owns is never a reason to block, skip, or refuse, and a fixture that demands an empty account must converge rather than refuse. Extension ensure_* and teardown_state only assert; converge there through an explicitly funded leaf action. Never broaden cleanup onto an account or market the task does not own; obtain an owned one instead of blocking. On mainnet, orders follow the shared Perps order rule (PERPS_MAINNET_ORDER_REFUSED), Core needs CORE_PERPS_ALLOW_MAINNET_WRITES=1 for any write, Mobile cleanup acts on whatever network the app is on, and Extension refuses mainnet writes other than orders and `close_visible_position` in the fixture-policy lane.",
205
+ "Rate limits: RATE_LIMITED means Hyperliquid answered 429 Too Many Requests and bounded retries did not get through; it says nothing about the product. Wait a minute and rerun a read. A placement left outcome-unknown is never rerun before the market's positions, orders and fills are checked.",
205
206
  {
206
207
  "text": "An accepted order can fill before the UI updates. Preserve its receipt even when the expected resting-order assertion fails; one empty read does not prove cleanup. Check the read API's cache and freshness options before adding polling. Reconcile returned margin against the pre-placement balance. A successful close does not turn the original failed proof into a pass.",
207
208
  "adapters": [