@deeeed/metamask-harness 0.92.39 → 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 +8 -0
- package/library/actions/mobile/perps/perps.mjs +97 -3
- package/library/actions/mobile/ui/locators.mjs +13 -5
- package/library/actions/shared/perps/order-policy.mjs +3 -1
- package/library/actions/shared/ui/locators.mjs +61 -1
- package/library/manifests/mobile.action-manifest.json +28 -2
- package/package.json +1 -1
package/CHANGELOG.md
CHANGED
|
@@ -2,6 +2,14 @@
|
|
|
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
|
+
|
|
5
13
|
## 0.92.39 - 2026-10-20
|
|
6
14
|
|
|
7
15
|
### Added
|
|
@@ -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
|
-
|
|
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
|
-
|
|
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
|
|
11
|
-
|
|
12
|
-
|
|
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(
|
|
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"],
|