@deeeed/metamask-harness 0.92.38 → 0.93.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.
package/CHANGELOG.md CHANGED
@@ -2,6 +2,20 @@
2
2
 
3
3
  ## Unreleased
4
4
 
5
+ ## 0.93.0 - 2026-10-21
6
+
7
+ ### Added
8
+
9
+ - Mobile `metamask.perps.place_order`, `ensure_positions` and `ensure_orders` take `margin_mode` (`isolated` or `cross`). The order carries it to `PerpsController.placeOrder`. The action then proves the venue holds that mode: the opened position's mode for a market order, or `getMarginModeLock` for a resting order. It fails when they differ or when no position appears. A resting order is refused before dispatch when the venue lock cannot be read, and a failed lock check names the order left resting. `ensure_orders` proves each reused order on its own market. `ensure_positions` refuses to reuse an open position in another mode, since the venue cannot change it while open. `margin_mode` applies only to `state=open`. The mainnet house limits refuse any mode but `isolated`. Omitted keeps the venue default (the open position's mode, else isolated). Extension and Core order actions do not take `margin_mode` yet.
10
+ - Mobile `metamask.perps.read_positions` (and every position the Perps actions report) includes `marginMode`, so recipes can assert a position's collateral mode as state.
11
+ - Mobile `ui.locators` takes `test_ids` and returns `byTestId` (each element's role, enabled state, `null` when unreported, and description), `missingTestIds` and `unobservedTestIds`. A recipe can assert a control's state by test ID instead of scanning the ranked list. It covers disabled and non-interactive elements, which the ranked list drops as press targets. With `test_ids` it re-reads the accessibility tree until each ID is present or `timeout_ms` (default 5000, capped by the action deadline) passes, because the native tree can trail the React tree while a sheet animates in. A truncated observation reports its gaps as `unobservedTestIds`, since it cannot prove absence. The Extension observation keeps only interactive elements, so Extension `ui.locators` does not take `test_ids`.
12
+
13
+ ## 0.92.39 - 2026-10-20
14
+
15
+ ### Added
16
+
17
+ - `mm-harness recipe-validate <recipe.json> [--artifact-manifest <file>] [--artifact-dir <dir>] [--action-manifest <file>] [--json]` validates a recipe and its retained Recipe v1 run package with the shared validator, the same checks publication runs. It exits 1 when the result is invalid. Skills that package PR evidence can validate a run through the task's own harness.
18
+
5
19
  ## 0.92.38 - 2026-10-20
6
20
 
7
21
  ### Changed
@@ -1003,6 +1003,36 @@ Example:
1003
1003
  positionals: [{ label: "action", choices: ["build", "plan", "advise", "advisor-status"] }]
1004
1004
  }
1005
1005
  },
1006
+ {
1007
+ name: "recipe-validate",
1008
+ summary: "Validate a recipe and its retained Recipe v1 artifact package.",
1009
+ example: "mm-harness recipe-validate artifacts/recipe-run/recipe.json --artifact-dir artifacts/recipe-run --json",
1010
+ helpText: `mm-harness recipe-validate <recipe.json> [flags]
1011
+
1012
+ Validates the recipe graph and, with --artifact-dir, the run package beside it:
1013
+ required files, manifest paths and digests, recorded parameters and resolved
1014
+ dependencies. The checks are the shared Recipe v1 validator's. Exits 1 when the
1015
+ result is invalid.
1016
+
1017
+ --artifact-manifest <file> artifact-manifest.json to validate
1018
+ --artifact-dir <dir> Artifact package directory (required files, manifest paths)
1019
+ --action-manifest <file> Runner action manifest for action checks
1020
+ --json Machine-readable result: { status, findings, summary }
1021
+
1022
+ Example:
1023
+ mm-harness recipe-validate artifacts/recipe-run/recipe.json \\
1024
+ --artifact-manifest artifacts/recipe-run/artifact-manifest.json \\
1025
+ --artifact-dir artifacts/recipe-run --json`,
1026
+ contract: {
1027
+ options: options(HELP, JSON, {
1028
+ "--artifact-manifest": value(),
1029
+ "--artifact-dir": value(),
1030
+ "--action-manifest": value()
1031
+ }),
1032
+ positionals: [{ label: "recipe" }],
1033
+ minimumPositionals: 1
1034
+ }
1035
+ },
1006
1036
  {
1007
1037
  name: "provision",
1008
1038
  summary: "Install the cached Runway iOS dev client on a prepared mobile slot (no deps, no Metro).",
@@ -0,0 +1,30 @@
1
+ import { optionFlag, optionString, parseArgs, usageError } from "@farmslot/recipe-cli/harness";
2
+ import { validateRecipeCliInput } from "@farmslot/recipe-runner/cli/support";
3
+ const USAGE = "Usage: mm-harness recipe-validate <recipe.json> [--artifact-manifest <file>] [--artifact-dir <dir>] [--action-manifest <file>] [--json]";
4
+ async function handleRecipeValidate(argv) {
5
+ const parsed = parseArgs(argv);
6
+ const [recipePath, ...extra] = parsed.positional;
7
+ if (!recipePath || extra.length > 0) throw usageError(USAGE);
8
+ const result = await validateRecipeCliInput({
9
+ recipePath,
10
+ actionManifestPath: optionString(parsed.options, "actionManifest"),
11
+ artifactManifestPath: optionString(parsed.options, "artifactManifest"),
12
+ artifactDir: optionString(parsed.options, "artifactDir")
13
+ });
14
+ if (optionFlag(parsed.options, "json")) {
15
+ process.stdout.write(`${JSON.stringify(result, null, 2)}
16
+ `);
17
+ } else {
18
+ const { errors, warnings } = result.summary;
19
+ process.stdout.write(`recipe validation: ${result.status} (${errors} errors, ${warnings} warnings)
20
+ `);
21
+ for (const finding of result.findings) {
22
+ process.stdout.write(`- ${finding.severity} ${finding.code} ${finding.path}: ${finding.message}
23
+ `);
24
+ }
25
+ }
26
+ return result.status === "invalid" ? 1 : 0;
27
+ }
28
+ export {
29
+ handleRecipeValidate
30
+ };
@@ -58,6 +58,7 @@ import { handleHelp } from "./commands/help.js";
58
58
  import { overlayCommandOptions } from "./commands/overlay.js";
59
59
  import { handlePin } from "./commands/pin.js";
60
60
  import { handlePrBody } from "./commands/pr-body.js";
61
+ import { handleRecipeValidate } from "./commands/recipe-validate.js";
61
62
  import { handleProvision } from "./commands/provision.js";
62
63
  import { handleResolveExtension } from "./commands/resolve-extension.js";
63
64
  import { handleRuntimeDecision } from "./commands/runtime-decision.js";
@@ -183,6 +184,7 @@ const PUBLIC_RUN = {
183
184
  },
184
185
  check: { run: handleCheck },
185
186
  "recipe-quality": { run: (args) => handleRecipeQuality(args, cliRecipeQualityOptions) },
187
+ "recipe-validate": { run: handleRecipeValidate },
186
188
  provision: { run: (args) => handleProvision(parseArgs(args)) },
187
189
  install: { run: overlay("install") },
188
190
  verify: { run: overlay("verify") },
@@ -228,7 +230,7 @@ const HELP_GROUPS = [
228
230
  {
229
231
  title: "PROVE",
230
232
  blurb: "run recipes and inspect readiness",
231
- commands: ["run", "last", "doctor", "check", "checklist", "review", "pr-body", "recipe-quality"]
233
+ commands: ["run", "last", "doctor", "check", "checklist", "review", "pr-body", "recipe-quality", "recipe-validate"]
232
234
  },
233
235
  {
234
236
  title: "RUNTIME OVERLAY",
@@ -349,6 +349,17 @@ function resolveOrderType(input) {
349
349
  return value;
350
350
  }
351
351
 
352
+ // Omitted keeps the venue default: the open position's mode, else isolated.
353
+ function resolveMarginMode(input) {
354
+ const raw = input.node?.margin_mode ?? input.node?.marginMode;
355
+ if (raw == null || raw === '') return undefined;
356
+ const value = String(raw).toLowerCase();
357
+ if (value !== 'isolated' && value !== 'cross') {
358
+ throw new Error(`${input.action} received unsupported margin_mode: ${value}.`);
359
+ }
360
+ return value;
361
+ }
362
+
352
363
  function resolveLimitPrice(input, isBuy, currentPrice) {
353
364
  const explicit = input.node?.limit_price ?? input.node?.limitPrice ?? input.node?.price;
354
365
  if (explicit != null && String(explicit).length > 0) {
@@ -428,6 +439,51 @@ async function readOpenOrders(input) {
428
439
  );
429
440
  }
430
441
 
442
+ // The mode the venue binds to a market through its resting orders or TWAPs;
443
+ // open orders themselves do not report it.
444
+ async function readMarginModeLock(input, symbol) {
445
+ return retryPerpsClientRead(
446
+ () => evalAsync(
447
+ input,
448
+ `(function(){
449
+ var controller = Engine && Engine.context && Engine.context.PerpsController;
450
+ if (!controller || typeof controller.getMarginModeLock !== 'function') {
451
+ throw new Error('Engine.context.PerpsController.getMarginModeLock is unavailable; this build cannot verify margin_mode.');
452
+ }
453
+ return controller.getMarginModeLock(${sourceLiteral({ symbol })}).then(function(r){ return JSON.stringify(r); });
454
+ })()`,
455
+ ),
456
+ { deadlineEpochMs: inheritedDeadline(input), call: 'PerpsController.getMarginModeLock' },
457
+ );
458
+ }
459
+
460
+ async function requireOrderMarginMode(input, symbol, marginMode, leftOver = '') {
461
+ const lock = await readMarginModeLock(input, symbol);
462
+ if (lock?.status !== 'locked' || lock.marginMode !== marginMode) {
463
+ throw new Error(`${input.action} requested margin_mode=${marginMode} for ${symbol}, but the venue reports ${JSON.stringify(lock)}.${leftOver}`);
464
+ }
465
+ return lock;
466
+ }
467
+
468
+ // A resting order is only provable through the venue lock, so refuse before
469
+ // dispatch when this build or provider cannot answer it.
470
+ async function requireMarginModeLockReadable(input, symbol) {
471
+ const lock = await readMarginModeLock(input, symbol);
472
+ if (lock?.status !== 'locked' && lock?.status !== 'unlocked') {
473
+ throw new Error(`${input.action} cannot verify margin_mode for ${symbol} before placing a resting order: the venue lock reports ${JSON.stringify(lock)}.`);
474
+ }
475
+ }
476
+
477
+ function requirePositionMarginMode(input, positions, marginMode) {
478
+ if (positions.length === 0) {
479
+ throw new Error(`${input.action} requested margin_mode=${marginMode}, but no matching position was observed to prove it.`);
480
+ }
481
+ const mismatched = positions.filter((position) => position.marginMode !== marginMode);
482
+ if (mismatched.length > 0) {
483
+ throw new Error(`${input.action} requested margin_mode=${marginMode}, but ${mismatched.map((p) => `${p.symbol} is ${p.marginMode}`).join(', ')}; the venue cannot change an open position's mode, so converge with state=none first.`);
484
+ }
485
+ }
486
+
431
487
  const TWAP_NOT_ADVERTISED = 'provider-does-not-advertise-twap';
432
488
 
433
489
  async function readStrategyOrders(input) {
@@ -831,6 +887,8 @@ function redactPosition(position) {
831
887
  size: position.size ?? position.szi ?? null,
832
888
  side: position.side ?? null,
833
889
  entryPrice: position.entryPrice ?? position.entryPx ?? null,
890
+ // Same source as the mainnet house-limit check, normalized to isolated|cross.
891
+ marginMode: String(position.leverage?.type ?? position.marginType ?? '').toLowerCase() || null,
834
892
  };
835
893
  }
836
894
 
@@ -1527,6 +1585,7 @@ export async function placeOrder(input, { ensure = false } = {}) {
1527
1585
  const maxSlippageBps = Number(input.node?.max_slippage_bps ?? input.node?.maxSlippageBps ?? 300);
1528
1586
  const isBuy = orderSideIsBuy(side);
1529
1587
  const orderType = resolveOrderType(input);
1588
+ const marginMode = resolveMarginMode(input);
1530
1589
  const closeImmediately = closeImmediatelyRequested(input.node);
1531
1590
  const runtime = await readPerpsRuntimeState(input);
1532
1591
  const network = orderNetwork({ declared: input.node?.network, liveIsTestnet: runtime?.isTestnet });
@@ -1539,6 +1598,7 @@ export async function placeOrder(input, { ensure = false } = {}) {
1539
1598
  orderType,
1540
1599
  closeImmediately,
1541
1600
  runCancelsLimit: !ensure,
1601
+ marginMode,
1542
1602
  projectRoot: input.context?.projectRoot,
1543
1603
  selectedAddress: bound.address,
1544
1604
  });
@@ -1586,6 +1646,7 @@ export async function placeOrder(input, { ensure = false } = {}) {
1586
1646
  currentPrice,
1587
1647
  priceAtCalculation: currentPrice,
1588
1648
  maxSlippageBps,
1649
+ ...(marginMode ? { marginMode } : {}),
1589
1650
  }
1590
1651
  : {
1591
1652
  symbol,
@@ -1597,7 +1658,9 @@ export async function placeOrder(input, { ensure = false } = {}) {
1597
1658
  currentPrice,
1598
1659
  priceAtCalculation: currentPrice,
1599
1660
  maxSlippageBps,
1661
+ ...(marginMode ? { marginMode } : {}),
1600
1662
  };
1663
+ if (marginMode && orderType === 'limit') await requireMarginModeLockReadable(input, symbol);
1601
1664
  if (await tradingContextChanged(input, bound)) {
1602
1665
  throw new Error('Perps order not dispatched: the selected account or network changed after the order rule allowed it.');
1603
1666
  }
@@ -1617,6 +1680,8 @@ export async function placeOrder(input, { ensure = false } = {}) {
1617
1680
  })
1618
1681
  : null;
1619
1682
  let result;
1683
+ let marginModeLock;
1684
+ let observedMarginMode;
1620
1685
  try {
1621
1686
  result = await evalAsync(input, `(function(){ ${boundContextGuard(bound)}
1622
1687
  return Engine.context.PerpsController.placeOrder(${sourceLiteral(orderParams)}).then(function(r){return JSON.stringify(r)}); })()`);
@@ -1646,12 +1711,21 @@ export async function placeOrder(input, { ensure = false } = {}) {
1646
1711
  } else {
1647
1712
  await waitForPositionPresent(input, symbol, Number(input.node?.timeout_ms ?? 30000));
1648
1713
  }
1714
+ if (marginMode && orderType === 'limit') {
1715
+ const resting = ` The ${symbol} order it placed (${result?.orderId ?? 'unknown id'}) is still resting; ${ledgerId ? "the run's teardown cancels it" : 'cancel it with ensure_orders state=none'}.`;
1716
+ marginModeLock = await requireOrderMarginMode(input, symbol, marginMode, resting);
1717
+ observedMarginMode = marginModeLock.marginMode;
1718
+ } else if (marginMode) {
1719
+ const opened = (await readBoundPositions(input, bound)).filter((position) => symbolForItem(position) === symbol);
1720
+ requirePositionMarginMode(input, opened.map(redactPosition), marginMode);
1721
+ observedMarginMode = redactPosition(opened[0]).marginMode;
1722
+ }
1649
1723
  } catch (error) {
1650
1724
  if (ledgerId && error?.knownNotDispatched) await dropMainnetOrder(input.context.projectRoot, ledgerId);
1651
1725
  if (!closeImmediately || error?.knownNotDispatched) throw error;
1652
1726
  throw await closeAfterFailedPlacement(input, symbol, error, bound);
1653
1727
  }
1654
- const placed = { action: input.action, market: symbol, side, orderType, amount, size, leverage, limitPrice, submitted: true, result, network, policy: policy.lane, ...(policy.autoCancel ? { autoCancel: policy.autoCancel } : {}), ...(houseNotional ? { houseNotional } : {}), proofPath: 'mobile-perps-controller-place-order' };
1728
+ const placed = { action: input.action, market: symbol, side, orderType, amount, size, leverage, ...(observedMarginMode ? { marginMode: observedMarginMode } : {}), ...(marginModeLock ? { marginModeLock } : {}), limitPrice, submitted: true, result, network, policy: policy.lane, ...(policy.autoCancel ? { autoCancel: policy.autoCancel } : {}), ...(houseNotional ? { houseNotional } : {}), proofPath: 'mobile-perps-controller-place-order' };
1655
1729
  if (!closeImmediately) return placed;
1656
1730
  return { ...placed, roundTrip: await closeOpenedPosition(input, symbol, bound) };
1657
1731
  }
@@ -1820,13 +1894,17 @@ export async function ensurePositions(input) {
1820
1894
  requireExplicitSelection(input);
1821
1895
  const state = String(input.node.state).toLowerCase();
1822
1896
  if (state === 'none' || state === 'closed' || state === 'absent') {
1897
+ refuseMarginModeForAbsentState(input);
1823
1898
  const close = await closePositions(input);
1824
1899
  return { ...(await assertPositions(input, false)), close };
1825
1900
  }
1826
1901
  if (state === 'open' || state === 'present') {
1827
1902
  const existing = await readPositions(input);
1828
1903
  if (selectedItems(input, existing).length === 0) await placeOrder(input, { ensure: true });
1829
- return assertPositions(input, true);
1904
+ const result = await assertPositions(input, true);
1905
+ const marginMode = resolveMarginMode(input);
1906
+ if (marginMode) requirePositionMarginMode(input, result.positions, marginMode);
1907
+ return result;
1830
1908
  }
1831
1909
  throw new Error(`metamask.perps.ensure_positions received unsupported state: ${state}`);
1832
1910
  }
@@ -1836,6 +1914,7 @@ export async function ensureOrders(input) {
1836
1914
  requireExplicitSelection(input);
1837
1915
  const state = String(input.node.state).toLowerCase();
1838
1916
  if (state === 'none' || state === 'closed' || state === 'absent') {
1917
+ refuseMarginModeForAbsentState(input);
1839
1918
  const close = await closeOrders(input);
1840
1919
  return { ...(await assertOrders(input, false)), close };
1841
1920
  }
@@ -1845,12 +1924,27 @@ export async function ensureOrders(input) {
1845
1924
  if (selectedItems(input, current).length === 0) {
1846
1925
  order = await placeOrder({ ...input, node: { ...input.node, order_type: 'limit' } }, { ensure: true });
1847
1926
  }
1848
- return { ...(await assertOrders(input, true)), order };
1927
+ const result = await assertOrders(input, true);
1928
+ const marginMode = resolveMarginMode(input);
1929
+ if (!marginMode) return { ...result, order };
1930
+ // A placed order was already proved inside placeOrder; reused orders are proved per market.
1931
+ const symbols = order ? [marketSymbol(input)] : [...new Set(result.orders.map((item) => item.symbol))];
1932
+ const locks = {};
1933
+ for (const symbol of symbols) {
1934
+ locks[symbol] = order ? order.marginModeLock : await requireOrderMarginMode(input, symbol, marginMode);
1935
+ }
1936
+ return { ...result, order, marginModeLock: locks[symbols[0]], marginModeLocks: locks };
1849
1937
  }
1850
1938
  throw new Error(`metamask.perps.ensure_orders received unsupported state: ${state}`);
1851
1939
  }
1852
1940
 
1853
1941
 
1942
+ function refuseMarginModeForAbsentState(input) {
1943
+ if (resolveMarginMode(input)) {
1944
+ throw new Error(`${input.action} margin_mode applies only to state=open; drop it for state=none.`);
1945
+ }
1946
+ }
1947
+
1854
1948
  function paramsForState(input) {
1855
1949
  const nested = input.node?.params && typeof input.node.params === 'object' ? input.node.params : {};
1856
1950
  return { ...nested, ...input.node };
@@ -1,18 +1,26 @@
1
1
  // Turn visible Mobile accessibility observations into UI locator candidates.
2
2
  import { observeNativeUi } from '../platform/observe-ui.mjs';
3
- import { runAdapter } from '../platform/bridge.mjs';
3
+ import { earliestDeadline, runAdapter } from '../platform/bridge.mjs';
4
4
  import {
5
5
  locatorsFromObservation,
6
+ observeRequestedTestIds,
7
+ requestedTestIds,
8
+ requestedTimeoutMs,
6
9
  requireVisibleObservation,
7
10
  } from '../../shared/ui/locators.mjs';
8
11
 
9
12
  runAdapter(async (input) => {
10
- const observed = await observeNativeUi(
11
- { refs: ['ui.visible'], node: input.node },
12
- input.context,
13
+ const testIds = requestedTestIds(input.node);
14
+ const observation = await observeRequestedTestIds(
15
+ async () => requireVisibleObservation(await observeNativeUi(
16
+ { refs: ['ui.visible'], node: input.node },
17
+ input.context,
18
+ )),
19
+ testIds,
20
+ earliestDeadline(input, Date.now() + requestedTimeoutMs(input.node)),
13
21
  );
14
22
  return {
15
23
  action: input.action,
16
- ...locatorsFromObservation(requireVisibleObservation(observed)),
24
+ ...locatorsFromObservation(observation, { testIds }),
17
25
  };
18
26
  });
@@ -62,10 +62,11 @@ export function closeImmediatelyRequested(node) {
62
62
  * @param {boolean} order.fixtureAccount - The selected account is a synced fixture account.
63
63
  * @param {string|null} [order.accountName] - Its fixture name.
64
64
  * @param {boolean} [order.runCancelsLimit] - The caller records a limit order for the run's forced cancel.
65
+ * @param {'isolated'|'cross'} [order.marginMode] - Explicit collateral mode; mainnet allows only isolated.
65
66
  * @returns {{ lane: 'testnet' } | { lane: 'mainnet-house-limits', notionalBaseUnits: string, autoCancel?: 'end-of-run' }}
66
67
  */
67
68
  export function decidePerpsOrder({
68
- network, amount, leverage, orderType = 'market', closeImmediately, fixtureAccount, accountName = null, runCancelsLimit = false,
69
+ network, amount, leverage, orderType = 'market', closeImmediately, fixtureAccount, accountName = null, runCancelsLimit = false, marginMode,
69
70
  }) {
70
71
  if (closeImmediately && orderType !== 'market') {
71
72
  throw new Error('close_immediately needs a market order: a resting limit order is not a position the action can close.');
@@ -80,6 +81,7 @@ export function decidePerpsOrder({
80
81
  if (amountBaseUnits <= 0n) broken.push('the amount is not positive');
81
82
  if (!fixtureAccount) broken.push('the selected account is not one of the synced test accounts');
82
83
  if (leverageValue !== MAINNET_HOUSE_LIMITS.leverage) broken.push(`leverage is ${leverage}, not 1`);
84
+ if (marginMode !== undefined && marginMode !== 'isolated') broken.push(`margin_mode is ${marginMode}, not isolated`);
83
85
  if (notionalBaseUnits > MAINNET_HOUSE_LIMITS.maxNotionalBaseUnits) {
84
86
  broken.push(`notional ${formatUsd(notionalBaseUnits)} is above $${MAINNET_HOUSE_LIMITS.maxNotionalUsd}`);
85
87
  }
@@ -21,7 +21,7 @@ const INTERACTIVE_ROLE_PARTS = [
21
21
  'cell',
22
22
  ];
23
23
 
24
- export function locatorsFromObservation(observation) {
24
+ export function locatorsFromObservation(observation, { testIds = [] } = {}) {
25
25
  const value = record(observation);
26
26
  const items = Array.isArray(value.items) ? value.items.map(record) : [];
27
27
  const ranked = items
@@ -44,9 +44,69 @@ export function locatorsFromObservation(observation) {
44
44
  count: locators.length,
45
45
  truncated: value.truncated === true || ranked.length > locators.length,
46
46
  locators,
47
+ ...(testIds.length > 0 ? visibleStateByTestId(items, testIds, value.truncated === true) : {}),
47
48
  };
48
49
  }
49
50
 
51
+ // State lookup for requested test IDs over every visible item, including
52
+ // disabled or non-interactive ones that are not press targets.
53
+ // A truncated observation cannot prove absence, so its gaps are reported as
54
+ // unobservedTestIds rather than missingTestIds.
55
+ function visibleStateByTestId(items, testIds, truncated) {
56
+ const byTestId = Object.create(null);
57
+ const absent = [];
58
+ for (const testId of testIds) {
59
+ const matches = items.filter((item) => text(item.test_id) === testId);
60
+ if (matches.length === 0) {
61
+ absent.push(testId);
62
+ continue;
63
+ }
64
+ const item = matches[0];
65
+ byTestId[testId] = {
66
+ matches: matches.length,
67
+ role: text(item.role),
68
+ enabled: typeof item.enabled === 'boolean' ? item.enabled : null,
69
+ ...(typeof item.selected === 'boolean' ? { selected: item.selected } : {}),
70
+ description: describe(item),
71
+ bounds: bounds(item.bounds),
72
+ };
73
+ }
74
+ return truncated
75
+ ? { byTestId, missingTestIds: [], unobservedTestIds: absent }
76
+ : { byTestId, missingTestIds: absent, unobservedTestIds: [] };
77
+ }
78
+
79
+ // The native accessibility tree can lag the React tree while a sheet animates
80
+ // in, so a requested test ID is re-read until observed or the bound expires.
81
+ export async function observeRequestedTestIds(observe, testIds, deadlineEpochMs) {
82
+ const pollMs = 250;
83
+ while (true) {
84
+ const observation = await observe();
85
+ if (testIds.length === 0) return observation;
86
+ const items = Array.isArray(observation?.items) ? observation.items : [];
87
+ const seen = new Set(items.map((item) => text(record(item).test_id)));
88
+ if (testIds.every((testId) => seen.has(testId))) return observation;
89
+ if (Date.now() + pollMs >= deadlineEpochMs) return observation;
90
+ await new Promise((resolve) => setTimeout(resolve, pollMs));
91
+ }
92
+ }
93
+
94
+ export function requestedTestIds(node) {
95
+ const value = node?.test_ids;
96
+ if (value == null) return [];
97
+ if (!Array.isArray(value) || !value.every((id) => typeof id === 'string' && id.trim())) {
98
+ throw new Error('ui.locators test_ids must be an array of non-empty test ID strings.');
99
+ }
100
+ return value.map((id) => id.trim());
101
+ }
102
+
103
+ export function requestedTimeoutMs(node) {
104
+ const value = node?.timeout_ms;
105
+ if (value == null) return 5000;
106
+ if (!Number.isFinite(value) || value < 0) throw new Error('ui.locators timeout_ms must be a non-negative number.');
107
+ return value;
108
+ }
109
+
50
110
  function locatorPriority(locator) {
51
111
  const role = text(locator.role)?.toLowerCase() ?? '';
52
112
  if (INTERACTIVE_ROLE_PARTS.some((part) => role.includes(part))) return 0;
@@ -1005,7 +1005,18 @@
1005
1005
  "execution_capabilities": ["host-read-export"],
1006
1006
  "schema": {
1007
1007
  "type": "object",
1008
- "properties": {},
1008
+ "properties": {
1009
+ "test_ids": {
1010
+ "type": "array",
1011
+ "items": { "type": "string" },
1012
+ "description": "Also report byTestId: the role, enabled state and description of each visible element with these test IDs, including disabled and non-interactive ones (enabled is null when unreported). Absent IDs are listed in missingTestIds, or in unobservedTestIds when the observation was truncated and cannot prove absence. Use it to assert control state; locators lists only press targets."
1013
+ },
1014
+ "timeout_ms": {
1015
+ "type": "number",
1016
+ "minimum": 0,
1017
+ "description": "With test_ids, re-read the observation until every requested test ID is present or this many milliseconds pass (default 5000)."
1018
+ }
1019
+ },
1009
1020
  "additionalProperties": false
1010
1021
  },
1011
1022
  "examples": [
@@ -2363,7 +2374,7 @@
2363
2374
  "execution_capabilities": ["app-mutation"]
2364
2375
  },
2365
2376
  "metamask.perps.read_positions": {
2366
- "description": "mobile Read live Perps positions; without a selector, return all live positions.",
2377
+ "description": "mobile Read live Perps positions; without a selector, return all live positions. Each position reports marginMode (isolated or cross) when the product exposes it.",
2367
2378
  "schema": {
2368
2379
  "type": "object",
2369
2380
  "properties": {
@@ -2840,6 +2851,11 @@
2840
2851
  "enum": ["market", "limit"],
2841
2852
  "default": "market"
2842
2853
  },
2854
+ "margin_mode": {
2855
+ "type": "string",
2856
+ "enum": ["isolated", "cross"],
2857
+ "description": "Collateral mode for this order (Hyperliquid; limit orders need a build with getMarginModeLock). The action proves the opened position or the venue lock carries it and fails otherwise. Omitted keeps the venue default: the open position mode, else isolated."
2858
+ },
2843
2859
  "limit_price": {
2844
2860
  "type": ["string", "number"],
2845
2861
  "description": "Limit price; omit it to offset from the current market price by offset_pct."
@@ -3128,6 +3144,11 @@
3128
3144
  "type": ["string", "number"],
3129
3145
  "description": "Alias for amount."
3130
3146
  },
3147
+ "margin_mode": {
3148
+ "type": "string",
3149
+ "enum": ["isolated", "cross"],
3150
+ "description": "Collateral mode for a created position. Also proves every selected open position carries it; an existing position in another mode fails, since the venue cannot change it while open."
3151
+ },
3131
3152
  "state": {
3132
3153
  "type": "string",
3133
3154
  "enum": ["none", "absent", "closed", "open", "present"],
@@ -3249,6 +3270,11 @@
3249
3270
  "default": 3,
3250
3271
  "description": "Testnet leverage used only when creating a missing open order."
3251
3272
  },
3273
+ "margin_mode": {
3274
+ "type": "string",
3275
+ "enum": ["isolated", "cross"],
3276
+ "description": "Collateral mode for a created resting order. Also proves the venue lock binds this mode, including for an order that already rested. Requires a build with getMarginModeLock."
3277
+ },
3252
3278
  "state": {
3253
3279
  "type": "string",
3254
3280
  "enum": ["none", "absent", "closed", "open", "present"],
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@deeeed/metamask-harness",
3
- "version": "0.92.38",
3
+ "version": "0.93.0",
4
4
  "type": "module",
5
5
  "bin": {
6
6
  "mm-harness": "bin/mm-harness"