@deeeed/metamask-harness 0.86.1 → 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 (37) hide show
  1. package/CHANGELOG.md +41 -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/close_visible_position.mjs +9 -5
  20. package/library/actions/extension/perps/compare_provider_market.mjs +12 -8
  21. package/library/actions/extension/perps/mutation-receipt.mjs +142 -24
  22. package/library/actions/extension/perps/perps.mjs +179 -40
  23. package/library/actions/extension/perps/read_snapshot.mjs +6 -2
  24. package/library/actions/extension/platform/cdp.mjs +6 -2
  25. package/library/actions/extension/ui/navigate.mjs +8 -0
  26. package/library/actions/mobile/perps/perps.mjs +109 -109
  27. package/library/actions/mobile/platform/bridge.mjs +192 -12
  28. package/library/actions/mobile/wallet/home.mjs +7 -2
  29. package/library/actions/mobile/wallet/select_account.mjs +4 -0
  30. package/library/actions/shared/app/live-adapter-deadline.mjs +8 -0
  31. package/library/actions/shared/perps/order-policy.mjs +19 -0
  32. package/library/actions/shared/perps/rate-limit.mjs +93 -0
  33. package/library/actions/shared/perps/visible-state.mjs +3 -2
  34. package/library/manifests/extension.action-manifest.json +3 -2
  35. package/library/manifests/mobile.action-manifest.json +30 -5
  36. package/package.json +1 -1
  37. package/site/assets/help-recipes.json +1 -0
@@ -109,6 +109,85 @@ function isRetryableMobileWaitError(error) {
109
109
  isRecord(error) && typeof error.code === "string" && RETRYABLE_MOBILE_WAIT_CODES.has(error.code)
110
110
  );
111
111
  }
112
+ function hiddenRouteExpression(testId) {
113
+ return `(function(){
114
+ const hook = globalThis.__REACT_DEVTOOLS_GLOBAL_HOOK__;
115
+ if (!hook || !hook.getFiberRoots) return null;
116
+ const hiddenAt = (props, routeKey) => {
117
+ const state = props.state;
118
+ const index = state.routes.findIndex((route) => route && route.key === routeKey);
119
+ if (index < 0 || typeof state.index !== 'number') return false;
120
+ if (state.type === 'tab') return index !== state.index;
121
+ if (state.type !== 'stack') return false;
122
+ // Mirror native-stack's getModalRouteKeys: an unset presentation after
123
+ // any modal-family screen is a modal too. On iOS a card pushed after a
124
+ // modal goes under it, so nothing hides a modal target.
125
+ const modal = [];
126
+ for (let position = 0; position <= state.index; position += 1) {
127
+ const descriptor = props.descriptors[state.routes[position].key];
128
+ if (!descriptor || !descriptor.options) return false;
129
+ const presentation = descriptor.options.presentation;
130
+ modal.push(presentation === undefined || presentation === null
131
+ ? modal.includes(true)
132
+ : !['card', 'push'].includes(presentation));
133
+ }
134
+ if (modal[index]) return false;
135
+ for (let above = index + 1; above <= state.index; above += 1) {
136
+ if (!modal[above]) return true;
137
+ }
138
+ return false;
139
+ };
140
+ const onHiddenRoute = (fiber) => {
141
+ let routeKey;
142
+ for (let owner = fiber.return; owner; owner = owner.return) {
143
+ const props = owner.memoizedProps;
144
+ if (!props) continue;
145
+ if (props.route && typeof props.route.key === 'string') routeKey = props.route.key;
146
+ if (routeKey && props.descriptors && props.state && Array.isArray(props.state.routes)
147
+ && hiddenAt(props, routeKey)) return true;
148
+ }
149
+ return false;
150
+ };
151
+ let matches = 0;
152
+ let hidden = 0;
153
+ const visit = (fiber) => {
154
+ for (; fiber; fiber = fiber.sibling) {
155
+ if (fiber.memoizedProps && fiber.memoizedProps.testID === ${JSON.stringify(testId)}) {
156
+ matches += 1;
157
+ try {
158
+ if (onHiddenRoute(fiber)) hidden += 1;
159
+ } catch (error) {
160
+ // An unreadable navigator counts as visible: never a false absent.
161
+ }
162
+ }
163
+ visit(fiber.child);
164
+ }
165
+ };
166
+ const roots = new Set();
167
+ for (const id of hook.renderers.keys()) (hook.getFiberRoots(id) || []).forEach((root) => roots.add(root));
168
+ roots.forEach((root) => visit(root.current));
169
+ return { matches, hidden };
170
+ })()`;
171
+ }
172
+ const NATIVE_READ_BUDGET_MS = 2500;
173
+ async function hiddenRouteOnly(input, probeInput, payload, testId, deadline) {
174
+ const routes = await evalSync(probeInput, hiddenRouteExpression(testId));
175
+ if (!isRecord(routes) || Number(routes.matches) < 1 || routes.hidden !== routes.matches) return null;
176
+ const remainingMs = deadline - Date.now();
177
+ if (remainingMs < NATIVE_READ_BUDGET_MS) return null;
178
+ let timer;
179
+ const observed = await Promise.race([
180
+ observeNativeUi({ refs: ["ui.visible"], node: payload }, input.context),
181
+ new Promise((resolve) => {
182
+ timer = setTimeout(() => resolve(null), remainingMs);
183
+ })
184
+ ]).finally(() => clearTimeout(timer));
185
+ if (!isRecord(observed) || Array.isArray(observed.warnings) && observed.warnings.length > 0) return null;
186
+ const visible = isRecord(observed.observations) ? observed.observations["ui.visible"] : void 0;
187
+ if (!isRecord(visible) || !Array.isArray(visible.items) || visible.truncated === true) return null;
188
+ const nativeVisible = visible.items.some((item) => isRecord(item) && item.test_id === testId && item.visible !== false);
189
+ return nativeVisible ? null : { hiddenRoute: true, nativeVisible: false };
190
+ }
112
191
  async function waitForMobileTarget(input, payload) {
113
192
  const timeoutMs = Number(payload.timeout_ms ?? payload.timeoutMs ?? 1e4);
114
193
  const deadline = Date.now() + timeoutMs;
@@ -162,8 +241,22 @@ async function waitForMobileTarget(input, payload) {
162
241
  );
163
242
  }
164
243
  lastResult = result;
165
- const present = result.present === true;
166
- const visible = result.visible === true;
244
+ let present = result.present === true;
245
+ let visible = result.visible === true;
246
+ let cachedRoute = null;
247
+ if (expectsAbsent && present && testId && visibility === "tree") {
248
+ try {
249
+ cachedRoute = await hiddenRouteOnly(input, probeInput, payload, testId, deadline);
250
+ } catch (error) {
251
+ if (!isRetryableMobileWaitError(error)) throw error;
252
+ lastBridgeError = error;
253
+ }
254
+ if (cachedRoute) {
255
+ present = false;
256
+ visible = false;
257
+ lastResult = { ...result, ...cachedRoute };
258
+ }
259
+ }
167
260
  let textMatched = !expectedText || textMatch === "contains";
168
261
  if (expectedText && textMatch === "exact" && textExpression) {
169
262
  try {
@@ -186,6 +279,7 @@ async function waitForMobileTarget(input, payload) {
186
279
  present,
187
280
  visible,
188
281
  visibility,
282
+ ...cachedRoute ?? {},
189
283
  ...isRecord(result.rect) ? { rect: result.rect } : {}
190
284
  };
191
285
  }
@@ -1,35 +1,50 @@
1
+ const ORDER_INPUTS = [
2
+ [["market", "symbol"], "market=<symbol> (or symbol=<symbol>)"],
3
+ [["side"], "side=long or side=short"],
4
+ [["amount", "notional"], "amount=<usd> or notional=<usd>"]
5
+ ];
6
+ function isRecord(value) {
7
+ return Boolean(value) && typeof value === "object" && !Array.isArray(value);
8
+ }
9
+ function opensState(value) {
10
+ const state = typeof value === "string" ? value.toLowerCase() : void 0;
11
+ return state === "open" || state === "present";
12
+ }
13
+ function hasValue(source, names) {
14
+ return names.some((name) => {
15
+ const value = source[name];
16
+ return value !== void 0 && value !== null && value !== "";
17
+ });
18
+ }
19
+ function missingOrderInputs(label, has, pathFor) {
20
+ return ORDER_INPUTS.filter(([names]) => !has(names)).map(([names, wanted]) => ({
21
+ severity: "error",
22
+ code: "recipe.missing_param",
23
+ path: pathFor(names[0]),
24
+ message: `${label} requires ${wanted} before it may create an order`
25
+ }));
26
+ }
1
27
  function perpsOrderInputFindings(nodeId, node, { ensureChecksOnly }) {
2
28
  const action = node.action;
3
- const state = typeof node.state === "string" ? node.state.toLowerCase() : void 0;
4
- const createsOrder = action === "metamask.perps.place_order" || !ensureChecksOnly && (action === "metamask.perps.ensure_positions" || action === "metamask.perps.ensure_orders") && (state === "open" || state === "present");
5
- if (!createsOrder) return [];
6
- const findings = [];
7
- const requireOneOf = (names, message) => {
8
- const present = names.some((name) => {
9
- const value = node[name];
10
- return value !== void 0 && value !== null && value !== "";
29
+ if (action === "metamask.perps.start_state") {
30
+ if (ensureChecksOnly) return [];
31
+ return ["positions", "orders"].flatMap((key) => {
32
+ const block = node[key];
33
+ if (!isRecord(block) || !opensState(block.state)) return [];
34
+ return missingOrderInputs(
35
+ `${action} ${key}.state=${String(block.state)}`,
36
+ (names) => hasValue(block, names) || names[0] !== "amount" && hasValue(node, names),
37
+ (name) => `workflow.nodes.${nodeId}.${key}.${name}`
38
+ );
11
39
  });
12
- if (present) return;
13
- findings.push({
14
- severity: "error",
15
- code: "recipe.missing_param",
16
- path: `workflow.nodes.${nodeId}.${names[0]}`,
17
- message
18
- });
19
- };
20
- requireOneOf(
21
- ["market", "symbol"],
22
- `${action} requires market=<symbol> (or symbol=<symbol>) before it may create an order`
23
- );
24
- requireOneOf(
25
- ["side"],
26
- `${action} requires side=long or side=short before it may create an order`
27
- );
28
- requireOneOf(
29
- ["amount", "notional"],
30
- `${action} requires amount=<usd> or notional=<usd> before it may create an order`
40
+ }
41
+ const createsOrder = action === "metamask.perps.place_order" || !ensureChecksOnly && (action === "metamask.perps.ensure_positions" || action === "metamask.perps.ensure_orders") && opensState(node.state);
42
+ if (!createsOrder) return [];
43
+ return missingOrderInputs(
44
+ String(action),
45
+ (names) => hasValue(node, names),
46
+ (name) => `workflow.nodes.${nodeId}.${name}`
31
47
  );
32
- return findings;
33
48
  }
34
49
  export {
35
50
  perpsOrderInputFindings
package/dist/adapters.js CHANGED
@@ -118,7 +118,10 @@ async function runLiveFirst(platform, action, node, context, forceLive = false,
118
118
  platform,
119
119
  action,
120
120
  namespace: LIBRARY_ACTION_NAMESPACE,
121
- node: withImmediateCloseBudget(action, node),
121
+ node: withImmediateCloseBudget(
122
+ action,
123
+ await getAdapterSurface(platform).actions.liveProcessNode?.(action, node, { ...process.env, ...context.env }) ?? node
124
+ ),
122
125
  context,
123
126
  prepared: preparedLiveAdapters?.get(action),
124
127
  contextExtras: trustedMutationContextExtras(context),
@@ -85,11 +85,14 @@ async function handleDoctor({ options }) {
85
85
  } catch (error2) {
86
86
  runtimeProbeError2 = error2 instanceof Error ? error2.message : String(error2);
87
87
  }
88
- const ready = runtime2?.decision === "ready";
88
+ const recorder2 = runtime2?.decision === "ready" ? await readiness.recorder?.() : void 0;
89
+ const ready2 = doctorReady(runtime2, recorder2);
89
90
  const block = readiness.runtimeBlock?.(target, runtime2);
90
91
  if (block) failed.push(block.id);
92
+ if (recorder2?.status === "fail") failed.push(recorder2.id);
91
93
  const nextActions = doctorFixNextActions(failed, result2.fixture, adapter, target, block);
92
- if (failed.length === 0 && !ready && runtime2?.nextAction) nextActions.push(runtime2.nextAction);
94
+ if (recorder2?.status === "fail" && recorder2.userAction) nextActions.push(recorder2.userAction);
95
+ if (failed.length === 0 && runtime2?.decision !== "ready" && runtime2?.nextAction) nextActions.push(runtime2.nextAction);
93
96
  const status = result2.status === "pass" && failed.length === 0 ? "pass" : "fail";
94
97
  const retryDoctor = `mm-harness doctor --fix --adapter ${adapter} --target ${shellQuote(target)} --json`;
95
98
  const fixUserAction = failed.includes("wallet-fixture") ? `choose one wallet fixture source from nextActions, run it, then retry: ${retryDoctor}` : nextActions[0] ?? retryDoctor;
@@ -98,15 +101,16 @@ async function handleDoctor({ options }) {
98
101
  message: "doctor --fix could not make every required check pass",
99
102
  userAction: fixUserAction
100
103
  } : void 0;
101
- if (json) console.log(JSON.stringify({ ...result2, status, fixed, failed, ready, runtime: runtime2, ...runtimeProbeError2 ? { runtimeProbeError: runtimeProbeError2 } : {}, nextActions, ...error ? { error } : {} }, null, 2));
104
+ if (json) console.log(JSON.stringify({ ...result2, status, fixed, failed, ready: ready2, runtime: runtime2, ...recorder2 ? { recorder: recorder2 } : {}, ...runtimeProbeError2 ? { runtimeProbeError: runtimeProbeError2 } : {}, nextActions, ...error ? { error } : {} }, null, 2));
102
105
  else {
103
- console.log(`${status} ${adapter} ${result2.compatibilityMode} ready=${ready} manifest=${actionManifestPath} fixed=[${fixed.join(",")}] failed=[${failed.join(",")}]`);
106
+ console.log(`${status} ${adapter} ${result2.compatibilityMode} ready=${ready2} manifest=${actionManifestPath} fixed=[${fixed.join(",")}] failed=[${failed.join(",")}]`);
104
107
  if (runtime2) {
105
108
  console.log(`runtime: decision=${runtime2.decision}${runtime2.reasonCode ? ` (${runtime2.reasonCode})` : ""}`);
106
109
  for (const reason of runtime2.reasons) console.log(` ${reason}`);
107
110
  } else if (runtimeProbeError2) {
108
111
  console.log(`runtime probe failed: ${runtimeProbeError2}`);
109
112
  }
113
+ if (recorder2) console.log(`recorder: ${recorder2.status} ${recorder2.message}`);
110
114
  for (const action of nextActions) console.error(` Next: ${action}`);
111
115
  }
112
116
  return status === "pass" ? 0 : 1;
@@ -154,10 +158,12 @@ async function handleDoctor({ options }) {
154
158
  runtimeProbeError = error instanceof Error ? error.message : String(error);
155
159
  }
156
160
  const runtimeCheck = doctorRuntimeCheck(runtime, runtimeProbeError, expectLive);
161
+ const recorder = runtime?.decision === "ready" ? await readiness.recorder?.() : void 0;
157
162
  const checks = [
158
163
  ...result.checks,
159
164
  runtimeCheck,
160
165
  ...await readiness.liveChecks?.(target) ?? [],
166
+ ...recorder ? [recorder] : [],
161
167
  ...await platformDoctorChecks(adapter, target),
162
168
  // Which library plugin code this process loaded: library, module and digest.
163
169
  ...adapterPluginChecks()
@@ -182,6 +188,7 @@ async function handleDoctor({ options }) {
182
188
  adapter,
183
189
  target,
184
190
  runtime,
191
+ recorder,
185
192
  doctorResult,
186
193
  orphanMetros,
187
194
  capture,
@@ -201,7 +208,8 @@ async function handleDoctor({ options }) {
201
208
  )?.userAction ?? `mm-harness doctor --fix --adapter ${adapter} --target ${shellQuote(target)} --json`;
202
209
  const doctorError = doctorResult.status === "fail" ? { code: "DOCTOR_CHECKS_FAILED", message: "one or more required doctor checks failed", userAction: doctorUserAction } : void 0;
203
210
  const next = doctorError?.userAction ?? discovery.deviceDiscoveryErrors?.[0]?.userAction ?? (runtime?.decision === "ready" ? void 0 : runtime?.nextAction);
204
- if (json) console.log(JSON.stringify({ ...doctorResult, ready: runtime?.decision === "ready", runtime, view, orphanMetros, capture, skills, update, featureFlags, devices, additionalReachableDevices, ...discovery, ...next ? { next } : {}, ...doctorError ? { error: doctorError } : {} }, null, 2));
211
+ const ready = doctorReady(runtime, recorder);
212
+ if (json) console.log(JSON.stringify({ ...doctorResult, ready, runtime, ...recorder ? { recorder } : {}, view, orphanMetros, capture, skills, update, featureFlags, devices, additionalReachableDevices, ...discovery, ...next ? { next } : {}, ...doctorError ? { error: doctorError } : {} }, null, 2));
205
213
  else {
206
214
  const out = (style, text) => color(style, text, { stream: process.stdout });
207
215
  const stateStyle = (value, good) => value === good ? "ok" : "warn";
@@ -222,6 +230,7 @@ async function handleDoctor({ options }) {
222
230
  } else if (runtimeProbeError) {
223
231
  console.log(`${out("err", "runtime probe failed:")} ${out("dim", runtimeProbeError)}`);
224
232
  }
233
+ if (recorder) console.log(`${out("label", "recorder:")} ${out(recorder.status === "pass" ? "ok" : "err", recorder.status)} ${recorder.message}`);
225
234
  console.log(renderCheckoutView(view));
226
235
  console.log(`${out("label", "feature flags:")} ${out(
227
236
  featureFlags.error || (featureFlags.overrideCount ?? 0) > 0 ? "warn" : "dim",
@@ -246,6 +255,9 @@ async function handleDoctor({ options }) {
246
255
  }
247
256
  return doctorResult.status === "pass" ? 0 : 1;
248
257
  }
258
+ function doctorReady(runtime, recorder) {
259
+ return runtime?.decision === "ready" && recorder?.status !== "fail";
260
+ }
249
261
  async function platformDoctorChecks(adapter, target) {
250
262
  try {
251
263
  return await getAdapterSurface(adapter).doctor?.(target) ?? [];
@@ -297,17 +309,23 @@ function doctorFixNextActions(failed, fixture, adapter, target, block) {
297
309
  if (block?.userAction && failed.includes(block.id)) actions.push(block.userAction);
298
310
  return actions;
299
311
  }
300
- function emitExpectLiveVerdict(adapter, target, runtime, verdict) {
312
+ function emitExpectLiveVerdict(adapter, target, runtime, verdict, recorder) {
301
313
  const out = (style, text) => color(style, text, { stream: process.stderr });
302
314
  const detail = runtime ? `${runtime.decision}${runtime.reasonCode ? ` (${runtime.reasonCode})` : ""}` : "runtime probe unavailable";
303
315
  console.error(`${out("err", verdict)} ${out("bold", adapter)} ${detail} ${out("dim", target)}`);
304
316
  for (const reason of runtime?.reasons ?? []) console.error(` ${out("dim", reason)}`);
317
+ if (recorder?.status === "fail") {
318
+ console.error(` ${out("err", "recorder:")} ${recorder.message}${recorder.detail ? ` ${out("dim", recorder.detail)}` : ""}`);
319
+ console.error(` Next: ${recorder.userAction}`);
320
+ return;
321
+ }
305
322
  console.error(` Next: ${runtime?.nextAction ?? getAdapterSurface(adapter).hints.runtimeProbeRecovery(target)}`);
306
323
  }
307
- function emitExpectLive(adapter, target, runtime, result, orphanMetros, capture, devices, additionalReachableDevices, json, printReady, discovery) {
324
+ function emitExpectLive(adapter, target, runtime, recorder, result, orphanMetros, capture, devices, additionalReachableDevices, json, printReady, discovery) {
308
325
  const live = runtime?.decision === "ready";
326
+ const ready = doctorReady(runtime, recorder);
309
327
  if (printReady) {
310
- if (live) {
328
+ if (ready) {
311
329
  const indicator = farmslotReadyIndicator(adapter, devices);
312
330
  if (indicator) {
313
331
  console.log(indicator);
@@ -316,21 +334,21 @@ function emitExpectLive(adapter, target, runtime, result, orphanMetros, capture,
316
334
  emitExpectLiveVerdict(adapter, target, runtime, "not-ready");
317
335
  return EXIT.runtime;
318
336
  }
319
- emitExpectLiveVerdict(adapter, target, runtime, "not-live");
337
+ emitExpectLiveVerdict(adapter, target, runtime, live ? "not-ready" : "not-live", recorder);
320
338
  return EXIT.runtime;
321
339
  }
322
340
  if (json) {
323
341
  const userAction = runtime?.nextAction ?? getAdapterSurface(adapter).hints.runtimeProbeRecovery(target);
324
- const error = live ? void 0 : { code: "RUNTIME_NOT_LIVE", message: `${adapter} runtime is not live`, userAction };
325
- console.log(JSON.stringify({ ...result, ready: live, runtime, orphanMetros, capture, devices, additionalReachableDevices, ...discovery, ...error ? { error } : {} }, null, 2));
326
- return live ? EXIT.ok : EXIT.runtime;
342
+ const error = !live ? { code: "RUNTIME_NOT_LIVE", message: `${adapter} runtime is not live`, userAction } : !ready ? { code: "RECORDER_UNAVAILABLE", message: recorder.message, userAction: recorder.userAction } : void 0;
343
+ console.log(JSON.stringify({ ...result, ready, runtime, ...recorder ? { recorder } : {}, orphanMetros, capture, devices, additionalReachableDevices, ...discovery, ...error ? { error } : {} }, null, 2));
344
+ return ready ? EXIT.ok : EXIT.runtime;
327
345
  }
328
346
  const out = (style, text) => color(style, text, { stream: process.stdout });
329
- if (live) {
347
+ if (ready) {
330
348
  console.log(`${out("ok", "live")} ${out("bold", adapter)} runtime ready`);
331
349
  return EXIT.ok;
332
350
  }
333
- emitExpectLiveVerdict(adapter, target, runtime, "not-live");
351
+ emitExpectLiveVerdict(adapter, target, runtime, live ? "not-ready" : "not-live", recorder);
334
352
  return EXIT.runtime;
335
353
  }
336
354
  function captureHealth(providers) {
@@ -457,6 +475,7 @@ async function runDoctorFix(adapter, target, manifestValidation, json) {
457
475
  }
458
476
  export {
459
477
  doctorFixNextActions,
478
+ doctorReady,
460
479
  handleDoctor,
461
480
  skillsView
462
481
  };
@@ -3,6 +3,7 @@ import fs from "node:fs";
3
3
  import { fixtureSummary } from "../doctor.js";
4
4
  import { normalizeFeatureFlagRecord } from "../feature-flags.js";
5
5
  import { mobileBridgeScript } from "../adapters/mobile/bridge-script.js";
6
+ import { isAndroidEmulatorSerial } from "../../library/actions/mobile/platform/bridge.mjs";
6
7
  const PROBE_TIMEOUT_MS = 2e3;
7
8
  function truncateAddress(address) {
8
9
  if (!address || address.length <= 10) return address;
@@ -51,7 +52,7 @@ function parseBridgeEntries(raw) {
51
52
  function androidModelMatches(deviceName, model, device, devices) {
52
53
  if (deviceName === model || deviceName.startsWith(`${model} -`)) return true;
53
54
  if (!deviceName.endsWith(` ${model}`) && !deviceName.includes(` ${model} -`)) return false;
54
- const physical = (d) => d.platform === "android" && !d.id.startsWith("emulator-");
55
+ const physical = (d) => d.platform === "android" && !isAndroidEmulatorSerial(d.id);
55
56
  if (!physical(device)) return false;
56
57
  return devices.filter((d) => physical(d) && d.name === device.name).length === 1;
57
58
  }
package/docs/RECIPES.md CHANGED
@@ -279,6 +279,18 @@ Perps orders follow one rule on Mobile, Extension and Core
279
279
  sent or closed.
280
280
  - Core still needs `CORE_PERPS_ALLOW_MAINNET_WRITES=1` for any mainnet write.
281
281
 
282
+ A Hyperliquid `429 Too Many Requests` fails as `RATE_LIMITED`, naming the call,
283
+ the attempts and the backoff (`library/actions/shared/perps/rate-limit.mjs`).
284
+ Reads retry up to 3 times with exponential backoff and jitter inside the action
285
+ deadline; then wait a minute and rerun. Extension background placement retries
286
+ only when the dispatch itself got the 429 and Hyperliquid's info API then shows
287
+ no open order, no fill and (market orders) no position on the order's side in
288
+ that market since the first dispatch. The receipt records every dispatch 429
289
+ under `rateLimitedDispatches`, with that check or the reason it did not retry;
290
+ without a retry it stays `outcome-unknown`: inspect the market before any rerun.
291
+ A 429 after dispatch is never redispatched, and Mobile placements are not
292
+ retried.
293
+
282
294
  On the Extension, a run without funding files gets that rule through the
283
295
  fixture-policy lane: the runner binds the run and a run-owned execution ledger
284
296
  (`<artifacts>/mutation-ledger`), and each mutation completes the context from
@@ -417,6 +429,56 @@ For visual PR proof, prefer a short simulator recording or GIF over still
417
429
  screenshots when the claim is a UI flow (timing / transitions). Use screenshots
418
430
  as supporting stills.
419
431
 
432
+ `--record-video=full-run` on a physical Android device records on the device
433
+ with `adb shell screenrecord`, so no other host screen-capture client can stop
434
+ it. screenrecord ends each file at 180 s; the harness starts the next one at
435
+ once and joins them with ffmpeg (required, or set `FFMPEG_PATH`). Each restart
436
+ leaves a gap of about half a second, which the video holds on the last frame
437
+ so video time keeps to wall-clock time. `videos/recipe-run.mp4.capture.json`
438
+ lists the files and their offsets. Screens the app marks secure (FLAG_SECURE)
439
+ record black. An emulator (`emulator-*`) still records its scrcpy window
440
+ through capture-helper, falling back to one screenrecord file when the mirror
441
+ is unavailable. Set `METAMASK_RECIPE_MOBILE_ANDROID_RECORDER` to
442
+ `screenrecord` or `mirror` to force either.
443
+
444
+ A recording that fails fails the run: the `record.video` trace entry starts
445
+ with `RECORDING_FAILED` or `RECORDER_INTERRUPTED`, names the cause, and says the
446
+ run has no video. When capture stopped mid-run but a finalized partial file was
447
+ kept (capture-helper 0.3.1's `stream_interrupted`, or the screenrecord files
448
+ finished before a failure), the video is published and its manifest entry's
449
+ `recorder.interrupted` gives the code, the cause and the video time up to the
450
+ last captured frame (`coveredMs`). `mm-harness doctor` shows the recorder for
451
+ the selected device and is not ready while it cannot record.
452
+
453
+ ### Mobile setup and command budgets
454
+
455
+ `metamask.perps.start_state` and `teardown_state` have three budgets:
456
+
457
+ | Budget | Node field | Emulator / simulator | Physical Android | Env override |
458
+ |---|---|---|---|---|
459
+ | Whole setup | `timeout_ms` | 30 s | 60 s | `MM_HARNESS_MOBILE_SETUP_TIMEOUT_MS` |
460
+ | Each navigation phase | `navigation_timeout_ms` | 15 s | 30 s | `MM_HARNESS_MOBILE_NAVIGATION_TIMEOUT_MS` |
461
+ | Each bridge command | `cdp_timeout_ms` | 15 s | 15 s | none |
462
+
463
+ A device counts as physical when the run has an explicit adb serial (`--device <serial>`
464
+ or `ADB_SERIAL`) that is not `emulator-*`. Node fields win over the env overrides.
465
+ Each phase (unlock, account, navigation, provider, network, cleanup) runs on the
466
+ rest of the setup budget, or on its slice for navigation, and each bridge command
467
+ inside it is capped by that phase deadline. `navigate` and Perps market/home
468
+ navigation use the same navigation default outside setups.
469
+
470
+ Errors say which budget ran out:
471
+
472
+ - `DOMAIN_SETUP_DEADLINE`: a setup phase ran out. The message names the action and
473
+ phase, the time it ran against its limit, and the setup's elapsed time and
474
+ limit. Raise `timeout_ms` or `navigation_timeout_ms`.
475
+ - `ACTION_DEADLINE`: the calling action's deadline refused or stopped a bridge
476
+ command. Route and mode waits treat it as "no answer yet" and end with their
477
+ own timeout message.
478
+ - `CDP_TIMEOUT` with `CDP_COMMAND_TIMEOUT` in the message: one bridge command hit
479
+ its own limit while the action still had time. That points at the bridge or app,
480
+ not the budget.
481
+
420
482
  ### Perps loading lifecycle validation
421
483
 
422
484
  The Mobile loading recipe shares one graph across Android and iOS. It requires
@@ -6,13 +6,15 @@ import {
6
6
  redactAccount,
7
7
  runAdapter,
8
8
  } from './_controller.mjs';
9
+ import { liveAdapterDeadline } from '../../shared/app/live-adapter-deadline.mjs';
10
+ import { withRateLimitRetry } from '../../shared/perps/rate-limit.mjs';
9
11
 
10
12
  export async function readAccount(input) {
11
13
  const { controller, accountAddress, network } = await getCoreController(input);
12
- const account = await controller.getAccountState({
14
+ const account = await withRateLimitRetry('PerpsController.getAccountState', () => controller.getAccountState({
13
15
  standalone: true,
14
16
  userAddress: accountAddress,
15
- });
17
+ }), { deadline: liveAdapterDeadline(input.node) });
16
18
  return {
17
19
  action: input.action,
18
20
  source: 'perps-controller-standalone',
@@ -7,13 +7,15 @@ import {
7
7
  runAdapter,
8
8
  selectedItems,
9
9
  } from './_controller.mjs';
10
+ import { liveAdapterDeadline } from '../../shared/app/live-adapter-deadline.mjs';
11
+ import { withRateLimitRetry } from '../../shared/perps/rate-limit.mjs';
10
12
 
11
13
  export async function readOrders(input) {
12
14
  const { controller, accountAddress, network } = await getCoreController(input);
13
- const orders = await controller.getOpenOrders({
15
+ const orders = await withRateLimitRetry('PerpsController.getOpenOrders', () => controller.getOpenOrders({
14
16
  standalone: true,
15
17
  userAddress: accountAddress,
16
- });
18
+ }), { deadline: liveAdapterDeadline(input.node) });
17
19
  const matching = selectedItems(input, orders);
18
20
  return {
19
21
  action: input.action,
@@ -7,13 +7,15 @@ import {
7
7
  runAdapter,
8
8
  selectedItems,
9
9
  } from './_controller.mjs';
10
+ import { liveAdapterDeadline } from '../../shared/app/live-adapter-deadline.mjs';
11
+ import { withRateLimitRetry } from '../../shared/perps/rate-limit.mjs';
10
12
 
11
13
  export async function readPositions(input) {
12
14
  const { controller, accountAddress, network } = await getCoreController(input);
13
- const positions = await controller.getPositions({
15
+ const positions = await withRateLimitRetry('PerpsController.getPositions', () => controller.getPositions({
14
16
  standalone: true,
15
17
  userAddress: accountAddress,
16
- });
18
+ }), { deadline: liveAdapterDeadline(input.node) });
17
19
  const matching = selectedItems(input, positions, 'position');
18
20
  return {
19
21
  action: input.action,
@@ -85,18 +85,23 @@ export function consentControllerExpression({
85
85
  throw new Error('Extension consent state is unavailable.');
86
86
  }
87
87
  const metamask = root?.metamask ?? root ?? {};
88
+ // Extension 13.51+ keeps marketing consent in AnalyticsController.optedInToMarketing
89
+ // (migration 228); older builds expose MetaMetricsController.dataCollectionForMarketing.
90
+ const dataCollectionForMarketing = typeof metamask.dataCollectionForMarketing === 'boolean'
91
+ ? metamask.dataCollectionForMarketing
92
+ : metamask.optedInToMarketing;
88
93
  // Debug UI state can lag an acknowledged setter until the next UI sync.
89
94
  // Background/snapshot reads have no such subscription to wait for.
90
95
  if ((capturedChrome || !storeState) && (
91
96
  typeof metamask.optedIn !== 'boolean' ||
92
- typeof metamask.dataCollectionForMarketing !== 'boolean'
97
+ typeof dataCollectionForMarketing !== 'boolean'
93
98
  )) {
94
99
  throw new Error('Extension consent state did not contain boolean consent fields.');
95
100
  }
96
101
  return {
97
102
  state: {
98
103
  optedIn: metamask.optedIn,
99
- dataCollectionForMarketing: metamask.dataCollectionForMarketing,
104
+ dataCollectionForMarketing,
100
105
  analyticsId: metamask.analyticsId ? 'set' : null
101
106
  },
102
107
  source: capturedChrome
@@ -421,10 +421,13 @@ export async function closeVisiblePosition(input) {
421
421
  if (partialAmount == null && (!confirmation.fullSizeSelected || !confirmation.marketOrder)) {
422
422
  throw new Error('Receipt-bound full close requires the visible 100% market-close form.');
423
423
  }
424
- // Without a placement receipt the baseline is the in-position balance, which excludes the released margin.
425
- const closeReleaseBaseUnits = placement ? null : parseUsdBaseUnits(confirmation.receive);
426
- if (!placement && closeReleaseBaseUnits == null) {
427
- throw new Error('Close without a placement receipt requires the visible close receive amount.');
424
+ // Without a placement receipt the baseline is the in-position balance, which excludes the position's
425
+ // margin. The close returns marginUsed (PnL is already in the balance); the modal receive cross-checks it.
426
+ const closeReleaseBaseUnits = placement ? null : exactBefore.marginBaseUnits;
427
+ const closeModalReceiveBaseUnits = placement ? null : parseUsdBaseUnits(confirmation.receive);
428
+ if (closeModalReceiveBaseUnits != null
429
+ && BigInt(absoluteDelta(closeModalReceiveBaseUnits, closeReleaseBaseUnits)) > BigInt(balanceToleranceBaseUnits)) {
430
+ throw new Error('Visible close receive amount disagrees with the position margin beyond the reconciliation tolerance.');
428
431
  }
429
432
  const closeSizing = partialAmount == null ? { size: sizeMatch[1], side: fundingSide === 'long' ? 'short' : 'long' } : null;
430
433
  let submissionObserver = null;
@@ -440,7 +443,8 @@ export async function closeVisiblePosition(input) {
440
443
  cleanupOnly,
441
444
  fundingBalance,
442
445
  tradingContext,
443
- ...(closeReleaseBaseUnits != null ? { closeReleaseBaseUnits } : {}),
446
+ ...(closeReleaseBaseUnits != null ? { closeReleaseBaseUnits, closeReleaseSource: 'position-margin' } : {}),
447
+ ...(closeModalReceiveBaseUnits != null ? { closeModalReceiveBaseUnits } : {}),
444
448
  ...(acceptedFill ? { acceptedFill } : {}),
445
449
  ...(closeSizing ? { closeSizing } : {}),
446
450
  },
@@ -1,6 +1,8 @@
1
1
  // Fetch production Hyperliquid data and compare it with the opened market's displayed values.
2
2
  // Also check the funding countdown and nonblank chart canvases.
3
3
  import { dataTestId, marketSymbol, runAdapter, withExtensionPage } from '../platform/cdp.mjs';
4
+ import { liveAdapterDeadline } from '../../shared/app/live-adapter-deadline.mjs';
5
+ import { withRateLimitRetry } from '../../shared/perps/rate-limit.mjs';
4
6
 
5
7
  function numeric(value) {
6
8
  const match = String(value ?? '').replace(/[$,]/gu, '').match(/-?[0-9]+(?:\.[0-9]+)?/u);
@@ -63,14 +65,16 @@ runAdapter(async (input) => {
63
65
  if (endpoint !== 'https://api.hyperliquid.xyz/info') {
64
66
  throw new Error('metamask.perps.compare_provider_market endpoint must be the Hyperliquid production info API.');
65
67
  }
66
- const response = await fetch(endpoint, {
67
- method: 'POST',
68
- headers: { 'content-type': 'application/json' },
69
- body: JSON.stringify({ type: 'metaAndAssetCtxs' }),
70
- signal: AbortSignal.timeout(Number(input.node?.timeout_ms ?? 15_000)),
71
- });
72
- if (!response.ok) throw new Error(`Hyperliquid market request failed with HTTP ${response.status}.`);
73
- const payload = await response.json();
68
+ const payload = await withRateLimitRetry('Hyperliquid metaAndAssetCtxs', async () => {
69
+ const response = await fetch(endpoint, {
70
+ method: 'POST',
71
+ headers: { 'content-type': 'application/json' },
72
+ body: JSON.stringify({ type: 'metaAndAssetCtxs' }),
73
+ signal: AbortSignal.timeout(Number(input.node?.timeout_ms ?? 15_000)),
74
+ });
75
+ if (!response.ok) throw new Error(`Hyperliquid market request failed with HTTP ${response.status}.`);
76
+ return response.json();
77
+ }, { deadline: liveAdapterDeadline(input.node) });
74
78
  const universe = payload?.[0]?.universe;
75
79
  const contexts = payload?.[1];
76
80
  const index = Array.isArray(universe) ? universe.findIndex((entry) => entry?.name === market) : -1;