@deeeed/metamask-harness 0.19.0 → 0.19.1

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,19 @@
2
2
 
3
3
  ## Unreleased
4
4
 
5
+ ## 0.19.1 - 2026-07-23
6
+
7
+ ### Added
8
+
9
+ - Mobile `ui.press` supports `long_press=true` for real long-press gestures such as resetting a keypad value.
10
+
11
+ ### Fixed
12
+
13
+ - Perps position and order assertions honor `timeout_ms` consistently on Mobile, Extension, and Core.
14
+ - Core Perps reads and assertions honor their explicit network instead of silently defaulting isolated actions to testnet.
15
+ - Mobile and Extension `ensure_orders state=open` create a resting testnet limit order when missing and recognize live buy/sell sides consistently.
16
+ - Reject invalid live-adapter process timeouts and use the safe default.
17
+
5
18
  ## 0.19.0 - 2026-07-22
6
19
 
7
20
  ### Added
@@ -254,6 +254,38 @@ const COMMANDS = {
254
254
  return { ...result, testId, deviceName };
255
255
  },
256
256
 
257
+ async 'long-press-test-id'(client, args, { deviceName } = {}) {
258
+ const testId = args[0];
259
+ if (!testId) {
260
+ throw new Error('Usage: long-press-test-id <testId>');
261
+ }
262
+ const expr = `(function() {
263
+ var hook = globalThis.__REACT_DEVTOOLS_GLOBAL_HOOK__;
264
+ if (!hook) return { ok: false, error: 'No React DevTools hook' };
265
+ var renderers = hook.renderers;
266
+ if (!renderers) return { ok: false, error: 'No renderers' };
267
+ var getFiberRoots = hook.getFiberRoots;
268
+ function walk(fiber) {
269
+ if (!fiber) return false;
270
+ var props = fiber.memoizedProps;
271
+ if (props && props.testID === ${JSON.stringify(testId)}) {
272
+ if (typeof props.onLongPress === 'function') { props.onLongPress(); return true; }
273
+ }
274
+ return walk(fiber.child) || walk(fiber.sibling);
275
+ }
276
+ for (var [id] of renderers) {
277
+ var roots = getFiberRoots ? getFiberRoots(id) : undefined;
278
+ if (!roots) continue;
279
+ var found = false;
280
+ roots.forEach(function(r) { if (!found) found = walk(r.current); });
281
+ if (found) return { ok: true, testId: ${JSON.stringify(testId)} };
282
+ }
283
+ return { ok: false, error: 'No component with testID=' + ${JSON.stringify(testId)} + ' found or no onLongPress' };
284
+ })()`;
285
+ const result = await cdpEval(client, expr);
286
+ return { ...result, testId, deviceName };
287
+ },
288
+
257
289
  async 'press-text'(client, args, { deviceName } = {}) {
258
290
  const text = args[0];
259
291
  if (!text) {
@@ -693,6 +725,7 @@ Commands:
693
725
  get-selected-account Get the currently selected account
694
726
  switch-account <address> Switch to account by address
695
727
  press-test-id <testId> Press a component by its testID prop
728
+ long-press-test-id <testId> Invoke a component's long-press gesture by testID
696
729
  press-text <text> Press a component containing visible text
697
730
  scroll-view [--test-id <id>] [--offset <n>] [--animated]
698
731
  Scroll a ScrollView/FlatList
package/dist/adapters.js CHANGED
@@ -320,8 +320,13 @@ async function handleMobilePress(payload, context) {
320
320
  }
321
321
  const target = text ?? identifiers[0];
322
322
  if (target.trim() === "") throw new Error("ui.press target must be non-empty.");
323
+ const longPress = payload.long_press === true;
324
+ if (longPress && text !== void 0) {
325
+ throw new Error("ui.press long_press=true requires test_id, testID, or selector targeting.");
326
+ }
323
327
  const input = mobileUiInput(context, "press", payload);
324
- return text !== void 0 ? bridgeCommand(input, ["press-text", target]) : bridgeCommand(input, ["press-test-id", target]);
328
+ if (text !== void 0) return bridgeCommand(input, ["press-text", target]);
329
+ return bridgeCommand(input, [longPress ? "long-press-test-id" : "press-test-id", target]);
325
330
  }
326
331
  async function handleMobileSetInput(payload, context) {
327
332
  const input = mobileUiInput(context, "set_input", payload);
@@ -312,6 +312,7 @@ async function runLiveAdapterScript({ platform, action, node, context, prepared
312
312
  `);
313
313
  const command = prepared ? commandForPrepared(context.projectRoot, platform) : commandFor(script, context.projectRoot, platform);
314
314
  const platformEnv = await platformAdapterEnv(platform, context.projectRoot, tempDir);
315
+ const processTimeoutMs = liveAdapterProcessTimeoutMs(node);
315
316
  const result = await runProcess(command.command, [...command.args, inputPath], {
316
317
  cwd: context.projectRoot,
317
318
  env: {
@@ -321,12 +322,12 @@ async function runLiveAdapterScript({ platform, action, node, context, prepared
321
322
  METAMASK_RECIPE_ADAPTER_INPUT: inputPath,
322
323
  METAMASK_RECIPE_ADAPTER_OUTPUT: outputPath
323
324
  },
324
- timeoutMs: Number(node.live_adapter_timeout_ms ?? node.timeout_ms ?? 6e4),
325
+ timeoutMs: processTimeoutMs,
325
326
  ...prepared ? { stdin: prepared.sourceText } : {}
326
327
  });
327
328
  try {
328
329
  if (result.timedOut) {
329
- throw new Error(`Live adapter ${script} timed out after ${Number(node.live_adapter_timeout_ms ?? node.timeout_ms ?? 6e4)}ms.`);
330
+ throw new Error(`Live adapter ${script} timed out after ${processTimeoutMs}ms.`);
330
331
  }
331
332
  if (result.exitCode !== 0) {
332
333
  throw new Error(`Live adapter ${script} exited ${result.exitCode}: ${result.stderr || result.stdout}`);
@@ -344,6 +345,17 @@ async function runLiveAdapterScript({ platform, action, node, context, prepared
344
345
  await rm(tempDir, { recursive: true, force: true });
345
346
  }
346
347
  }
348
+ function liveAdapterProcessTimeoutMs(node) {
349
+ if (node.live_adapter_timeout_ms != null) {
350
+ const timeoutMs = Number(node.live_adapter_timeout_ms);
351
+ return Number.isFinite(timeoutMs) && timeoutMs > 0 ? timeoutMs : 6e4;
352
+ }
353
+ if (node.timeout_ms != null) {
354
+ const timeoutMs = Number(node.timeout_ms);
355
+ return Number.isFinite(timeoutMs) && timeoutMs > 0 ? timeoutMs + 5e3 : 6e4;
356
+ }
357
+ return 6e4;
358
+ }
347
359
  function commandForPrepared(projectRoot, platform) {
348
360
  if (platform !== "core") {
349
361
  return { command: process.execPath, args: ["--input-type=module", "-"] };
@@ -364,6 +376,7 @@ function isPathWithin(root, candidate) {
364
376
  return relative === "" || relative !== ".." && !relative.startsWith(`..${path.sep}`) && !path.isAbsolute(relative);
365
377
  }
366
378
  export {
379
+ liveAdapterProcessTimeoutMs,
367
380
  prepareLiveAdapterScript,
368
381
  resolveLiveAdapter,
369
382
  runLiveAdapterScript
package/docs/RECIPES.md CHANGED
@@ -67,6 +67,21 @@ Rules:
67
67
  - Parameterize repeated behavior instead of multiplying names.
68
68
  - Keep secrets out of recipes, libraries, and evidence.
69
69
 
70
+ Keep responsibilities narrow:
71
+
72
+ - **Parameters** expose caller choices that may vary without changing the
73
+ journey's meaning.
74
+ - **Recipes** compose actions and own journey order, safe defaults, invariants,
75
+ and proof.
76
+ - **Actions** translate one stable operation to existing UI, CDP, or controller
77
+ capabilities; they do not reimplement product business logic.
78
+ - **Product/controllers** remain the source of truth for state transitions,
79
+ validation, transactions, and domain behavior.
80
+
81
+ If changing a value would violate the recipe's postcondition, keep it as a
82
+ documented recipe invariant rather than a parameter. Add a shared action only
83
+ when repeated direct access has a stable cross-task contract.
84
+
70
85
  The protocol is authoritative:
71
86
  <https://farmslot.io/docs/reference/recipe-protocol-v1>.
72
87
 
@@ -23,11 +23,22 @@ export function expectedOpen(input) {
23
23
  export async function assertOrders(input, expectOpen = expectedOpen(input)) {
24
24
  requireExplicitSelection(input);
25
25
  const { controller, accountAddress, network } = await getCoreController(input);
26
- const orders = await controller.getOpenOrders({
27
- standalone: true,
28
- userAddress: accountAddress,
29
- });
30
- const matching = selectedItems(input, orders);
26
+ const timeoutMs = Number(input.node?.timeout_ms ?? 0);
27
+ const deadline = Date.now() + Math.max(0, timeoutMs);
28
+ let orders;
29
+ let matching;
30
+ while (true) {
31
+ orders = await controller.getOpenOrders({
32
+ standalone: true,
33
+ userAddress: accountAddress,
34
+ });
35
+ matching = selectedItems(input, orders);
36
+ if (expectOpen ? matching.length > 0 : matching.length === 0) break;
37
+ if (Date.now() >= deadline) break;
38
+ await new Promise((resolve) =>
39
+ setTimeout(resolve, Math.min(500, Math.max(1, deadline - Date.now()))),
40
+ );
41
+ }
31
42
  const hasOrder = matching.length > 0;
32
43
 
33
44
  if (expectOpen && !hasOrder) {
@@ -22,11 +22,22 @@ export function expectedOpen(input) {
22
22
  export async function assertPositions(input, expectOpen = expectedOpen(input)) {
23
23
  requireExplicitSelection(input);
24
24
  const { controller, accountAddress, network } = await getCoreController(input);
25
- const positions = await controller.getPositions({
26
- standalone: true,
27
- userAddress: accountAddress,
28
- });
29
- const matching = selectedItems(input, positions);
25
+ const timeoutMs = Number(input.node?.timeout_ms ?? 0);
26
+ const deadline = Date.now() + Math.max(0, timeoutMs);
27
+ let positions;
28
+ let matching;
29
+ while (true) {
30
+ positions = await controller.getPositions({
31
+ standalone: true,
32
+ userAddress: accountAddress,
33
+ });
34
+ matching = selectedItems(input, positions);
35
+ if (expectOpen ? matching.length > 0 : matching.length === 0) break;
36
+ if (Date.now() >= deadline) break;
37
+ await new Promise((resolve) =>
38
+ setTimeout(resolve, Math.min(500, Math.max(1, deadline - Date.now()))),
39
+ );
40
+ }
30
41
  const hasPosition = matching.length > 0;
31
42
 
32
43
  if (expectOpen && !hasPosition) {
@@ -10,7 +10,10 @@ function symbolForItem(item) {
10
10
  }
11
11
 
12
12
  function sideForItem(item) {
13
- return String(item?.side ?? item?.direction ?? '').toLowerCase();
13
+ const side = String(item?.side ?? item?.direction ?? '').toLowerCase();
14
+ if (side === 'buy' || side === 'b') return 'long';
15
+ if (side === 'sell' || side === 'a' || side === 's') return 'short';
16
+ return side;
14
17
  }
15
18
 
16
19
  function uniqueSymbols(symbols) {
@@ -57,6 +60,35 @@ function requirePlaceOrderInputs(input) {
57
60
  if (node.amount == null && node.notional == null) throw new Error(`${input.action} requires amount=<usd> or notional=<usd>.`);
58
61
  }
59
62
 
63
+ function resolveOrderType(input) {
64
+ const value = String(input.node?.order_type ?? input.node?.orderType ?? 'market').toLowerCase();
65
+ if (value !== 'market' && value !== 'limit') {
66
+ throw new Error(`${input.action} received unsupported order_type: ${value}.`);
67
+ }
68
+ return value;
69
+ }
70
+
71
+ function resolveLimitPrice(input, isBuy, currentPrice) {
72
+ const explicit = input.node?.limit_price ?? input.node?.limitPrice ?? input.node?.price;
73
+ if (explicit != null && String(explicit).length > 0) {
74
+ const value = Number(explicit);
75
+ if (!Number.isFinite(value) || value <= 0) {
76
+ throw new Error(`${input.action} received invalid limit_price: ${explicit}.`);
77
+ }
78
+ return value;
79
+ }
80
+ const rawOffset = input.node?.offset_pct ?? input.node?.offsetPct;
81
+ const offset = rawOffset == null ? (isBuy ? -30 : 30) : Number(rawOffset);
82
+ if (!Number.isFinite(offset)) {
83
+ throw new Error(`${input.action} received invalid offset_pct: ${rawOffset}.`);
84
+ }
85
+ const value = currentPrice * (1 + offset / 100);
86
+ if (!Number.isFinite(value) || value <= 0) {
87
+ throw new Error(`${input.action} computed a non-positive limit price (${value}).`);
88
+ }
89
+ return value;
90
+ }
91
+
60
92
  export function positionSelectionState(input, positions) {
61
93
  return { hasMatching: selectedItems(input, positions).length > 0 };
62
94
  }
@@ -97,9 +129,9 @@ export async function readPositions(input) {
97
129
 
98
130
  export async function readOrders(input) {
99
131
  return withExtensionPage(input, async (page) => {
100
- const orders = await readOpenOrders(page);
101
- const matching = selectedItems(input, orders);
102
- return { action: input.action, source: 'perps-stream-manager-cache', count: orders.length, matchingCount: matching.length, orders: matching.map(redactOrder) };
132
+ const state = await readOpenOrdersState(page);
133
+ const matching = selectedItems(input, state.orders);
134
+ return { action: input.action, source: state.source, count: state.orders.length, matchingCount: matching.length, orders: matching.map(redactOrder) };
103
135
  });
104
136
  }
105
137
 
@@ -107,11 +139,7 @@ export async function assertPositions(input, expectedOpen) {
107
139
  requireExplicitSelection(input);
108
140
  return withExtensionPage(input, async (page) => {
109
141
  const timeoutMs = Number(input.node?.timeout_ms ?? 0);
110
- const requested = configuredSymbols(input, []);
111
- if (expectedOpen && timeoutMs > 0 && requested.length === 1) {
112
- await waitForPositionPresent(page, requested[0], timeoutMs);
113
- }
114
- const state = await page.readPositions();
142
+ const state = await waitForSelectedPositionState(page, input, expectedOpen, timeoutMs);
115
143
  if (!state.available) throw new Error('stateHooks.submitRequestToBackground is unavailable; cannot assert live Perps positions.');
116
144
  const matching = selectedItems(input, state.positions);
117
145
  const hasPosition = matching.length > 0;
@@ -124,12 +152,13 @@ export async function assertPositions(input, expectedOpen) {
124
152
  export async function assertOrders(input, expectedOpen) {
125
153
  requireExplicitSelection(input);
126
154
  return withExtensionPage(input, async (page) => {
127
- const orders = await readOpenOrders(page);
128
- const matching = selectedItems(input, orders);
155
+ const timeoutMs = Number(input.node?.timeout_ms ?? 0);
156
+ const state = await waitForSelectedOrderState(page, input, expectedOpen, timeoutMs);
157
+ const matching = selectedItems(input, state.orders);
129
158
  const hasOrders = matching.length > 0;
130
159
  if (expectedOpen && !hasOrders) throw new Error('Expected at least one matching open Perps order, but found none.');
131
160
  if (!expectedOpen && hasOrders) throw new Error(`Expected no matching open Perps orders, but found ${matching.length}.`);
132
- return { action: input.action, expectedOpen, matchingCount: matching.length, orders: matching.map(redactOrder), source: 'perps-stream-manager-cache' };
161
+ return { action: input.action, expectedOpen, matchingCount: matching.length, orders: matching.map(redactOrder), source: state.source };
133
162
  });
134
163
  }
135
164
 
@@ -306,6 +335,10 @@ async function readCurrentMarketPrice(page, symbol, explicitPrice) {
306
335
  }
307
336
 
308
337
  async function readOpenOrders(page) {
338
+ return (await readOpenOrdersState(page)).orders;
339
+ }
340
+
341
+ async function readOpenOrdersState(page) {
309
342
  const state = await page.evaluate(`(async () => {
310
343
  const request = globalThis.stateHooks?.submitRequestToBackground;
311
344
  const manager = globalThis.stateHooks?.getPerpsStreamManager?.();
@@ -326,7 +359,7 @@ async function readOpenOrders(page) {
326
359
  };
327
360
  })()`);
328
361
  if (!state.available) throw new Error('Perps orders are unavailable from both background and stream manager cache.');
329
- return state.orders;
362
+ return state;
330
363
  }
331
364
 
332
365
  async function readAccountState(page) {
@@ -393,12 +426,41 @@ async function waitForPositionPresent(page, symbol, timeoutMs) {
393
426
  return lastState;
394
427
  }
395
428
 
429
+ async function waitForSelectedPositionState(page, input, expectedOpen, timeoutMs) {
430
+ const deadline = Date.now() + Math.max(0, timeoutMs);
431
+ let state;
432
+ while (true) {
433
+ state = await page.readPositions();
434
+ if (!state.available) {
435
+ throw new Error('stateHooks.submitRequestToBackground is unavailable; cannot assert live Perps positions.');
436
+ }
437
+ const matching = selectedItems(input, state.positions);
438
+ if (expectedOpen ? matching.length > 0 : matching.length === 0) return state;
439
+ if (Date.now() >= deadline) return state;
440
+ await sleep(Math.min(500, Math.max(1, deadline - Date.now())));
441
+ }
442
+ }
443
+
444
+ async function waitForSelectedOrderState(page, input, expectedOpen, timeoutMs) {
445
+ const deadline = Date.now() + Math.max(0, timeoutMs);
446
+ let state;
447
+ while (true) {
448
+ state = await readOpenOrdersState(page);
449
+ const matching = selectedItems(input, state.orders);
450
+ if (expectedOpen ? matching.length > 0 : matching.length === 0) return state;
451
+ if (Date.now() >= deadline) return state;
452
+ await sleep(Math.min(500, Math.max(1, deadline - Date.now())));
453
+ }
454
+ }
455
+
396
456
  export async function placeOrder(input) {
397
457
  requirePlaceOrderInputs(input);
398
458
  const symbol = marketSymbol(input);
399
459
  const side = String(input.node.side).toLowerCase();
400
460
  const amount = String(input.node.amount ?? input.node.notional);
401
461
  const leverage = Number(input.node?.leverage ?? 3);
462
+ const isBuy = side !== 'short';
463
+ const orderType = resolveOrderType(input);
402
464
  return withExtensionPage(input, async (page) => {
403
465
  await page.navigateHash(`#/perps/market/${encodeURIComponent(symbol)}`);
404
466
  await page.evaluate(`(async () => {
@@ -415,6 +477,9 @@ export async function placeOrder(input) {
415
477
  symbol,
416
478
  input.node?.current_price ?? input.node?.currentPrice,
417
479
  );
480
+ const limitPrice = orderType === 'limit'
481
+ ? resolveLimitPrice(input, isBuy, resolvedPrice)
482
+ : null;
418
483
  await page.navigateHash(`#/perps/trade/${encodeURIComponent(symbol)}?direction=${encodeURIComponent(side)}&mode=new`);
419
484
  await page.waitForSelector(dataTestId('perps-order-entry-page'), { timeoutMs: 20000 });
420
485
  let order;
@@ -426,13 +491,27 @@ export async function placeOrder(input) {
426
491
  if (!Number.isFinite(currentPrice) || currentPrice <= 0) throw new Error('Unable to determine current Perps price for order placement.');
427
492
  const usdAmount = ${JSON.stringify(amount)};
428
493
  const leverage = ${JSON.stringify(leverage)};
429
- const orderParams = {
494
+ const orderType = ${JSON.stringify(orderType)};
495
+ const limitPrice = ${JSON.stringify(limitPrice)};
496
+ const orderParams = orderType === 'limit' ? {
430
497
  symbol: ${JSON.stringify(symbol)},
431
- isBuy: ${JSON.stringify(side !== 'short')},
498
+ isBuy: ${JSON.stringify(isBuy)},
499
+ size: ((Number(usdAmount) * Number(leverage)) / Number(limitPrice)).toString(),
500
+ orderType,
501
+ price: String(limitPrice),
502
+ timeInForce: 'GTC',
503
+ leverage,
504
+ currentPrice,
505
+ priceAtCalculation: currentPrice,
506
+ maxSlippageBps: ${JSON.stringify(Number(input.node?.max_slippage_bps ?? input.node?.maxSlippageBps ?? 300))},
507
+ } : {
508
+ symbol: ${JSON.stringify(symbol)},
509
+ isBuy: ${JSON.stringify(isBuy)},
432
510
  size: ((Number(usdAmount) * Number(leverage)) / currentPrice).toString(),
433
- orderType: 'market',
511
+ orderType,
434
512
  leverage,
435
513
  currentPrice,
514
+ priceAtCalculation: currentPrice,
436
515
  usdAmount,
437
516
  maxSlippageBps: ${JSON.stringify(Number(input.node?.max_slippage_bps ?? input.node?.maxSlippageBps ?? 300))},
438
517
  };
@@ -446,8 +525,12 @@ export async function placeOrder(input) {
446
525
  const message = error instanceof Error ? error.message : String(error);
447
526
  throw new Error(`${message} (resolvedPrice=${resolvedPrice})`);
448
527
  }
449
- await waitForPositionPresent(page, symbol, Number(input.node?.timeout_ms ?? 30000));
450
- return { action: input.action, market: symbol, side, amount, leverage, submitted: true, order, proofPath: 'background-perpsPlaceOrder' };
528
+ if (orderType === 'limit') {
529
+ await waitForSelectedOrderState(page, input, true, Number(input.node?.timeout_ms ?? 30000));
530
+ } else {
531
+ await waitForPositionPresent(page, symbol, Number(input.node?.timeout_ms ?? 30000));
532
+ }
533
+ return { action: input.action, market: symbol, side, orderType, amount, leverage, limitPrice, submitted: true, order, proofPath: 'background-perpsPlaceOrder' };
451
534
  });
452
535
  }
453
536
 
@@ -479,7 +562,16 @@ export async function ensureOrders(input) {
479
562
  const close = await closeOrders(input);
480
563
  return { ...(await assertOrders(input, false)), close };
481
564
  }
482
- if (state === 'open' || state === 'present') return assertOrders(input, true);
565
+ if (state === 'open' || state === 'present') {
566
+ const current = await withExtensionPage(input, async (page) => ({
567
+ orders: await readOpenOrders(page),
568
+ }));
569
+ let order = null;
570
+ if (selectedItems(input, current.orders).length === 0) {
571
+ order = await placeOrder({ ...input, node: { ...input.node, order_type: 'limit' } });
572
+ }
573
+ return { ...(await assertOrders(input, true)), order };
574
+ }
483
575
  throw new Error(`metamask.perps.ensure_orders received unsupported state: ${state}`);
484
576
  }
485
577
 
@@ -24,7 +24,10 @@ function symbolForItem(item) {
24
24
  }
25
25
 
26
26
  function sideForItem(item) {
27
- return String(item?.side ?? item?.direction ?? '').toLowerCase();
27
+ const side = String(item?.side ?? item?.direction ?? '').toLowerCase();
28
+ if (side === 'buy' || side === 'b') return 'long';
29
+ if (side === 'sell' || side === 'a' || side === 's') return 'short';
30
+ return side;
28
31
  }
29
32
 
30
33
  function configuredSymbols(input, items) {
@@ -67,6 +70,35 @@ function requirePlaceOrderInputs(input) {
67
70
  if (node.amount == null && node.notional == null) throw new Error(`${input.action} requires amount=<usd> or notional=<usd>.`);
68
71
  }
69
72
 
73
+ function resolveOrderType(input) {
74
+ const value = String(input.node?.order_type ?? input.node?.orderType ?? 'market').toLowerCase();
75
+ if (value !== 'market' && value !== 'limit') {
76
+ throw new Error(`${input.action} received unsupported order_type: ${value}.`);
77
+ }
78
+ return value;
79
+ }
80
+
81
+ function resolveLimitPrice(input, isBuy, currentPrice) {
82
+ const explicit = input.node?.limit_price ?? input.node?.limitPrice ?? input.node?.price;
83
+ if (explicit != null && String(explicit).length > 0) {
84
+ const value = Number(explicit);
85
+ if (!Number.isFinite(value) || value <= 0) {
86
+ throw new Error(`${input.action} received invalid limit_price: ${explicit}.`);
87
+ }
88
+ return value;
89
+ }
90
+ const rawOffset = input.node?.offset_pct ?? input.node?.offsetPct;
91
+ const offset = rawOffset == null ? (isBuy ? -30 : 30) : Number(rawOffset);
92
+ if (!Number.isFinite(offset)) {
93
+ throw new Error(`${input.action} received invalid offset_pct: ${rawOffset}.`);
94
+ }
95
+ const value = currentPrice * (1 + offset / 100);
96
+ if (!Number.isFinite(value) || value <= 0) {
97
+ throw new Error(`${input.action} computed a non-positive limit price (${value}).`);
98
+ }
99
+ return value;
100
+ }
101
+
70
102
  function uniqueSymbols(symbols) {
71
103
  return Array.from(new Set(symbols.filter(Boolean)));
72
104
  }
@@ -143,6 +175,20 @@ async function waitForPositionPresent(input, symbol, timeoutMs = 30000) {
143
175
  return last;
144
176
  }
145
177
 
178
+ async function waitForSelectedState(input, readItems, expectedOpen, timeoutMs) {
179
+ const deadline = Date.now() + Math.max(0, timeoutMs);
180
+ let items;
181
+ while (true) {
182
+ items = await readItems(input);
183
+ const matching = selectedItems(input, items);
184
+ if (expectedOpen ? matching.length > 0 : matching.length === 0) {
185
+ return matching;
186
+ }
187
+ if (Date.now() >= deadline) return matching;
188
+ await sleep(Math.min(500, Math.max(1, deadline - Date.now())));
189
+ }
190
+ }
191
+
146
192
  function redactPosition(position) {
147
193
  return {
148
194
  symbol: position.symbol ?? position.coin ?? null,
@@ -270,8 +316,13 @@ export async function readOrders(input) {
270
316
 
271
317
  export async function assertPositions(input, expectedOpen) {
272
318
  requireExplicitSelection(input);
273
- const positions = await readPositions(input);
274
- const matching = selectedItems(input, positions);
319
+ const timeoutMs = Number(input.node?.timeout_ms ?? 0);
320
+ const matching = await waitForSelectedState(
321
+ input,
322
+ readPositions,
323
+ expectedOpen,
324
+ timeoutMs,
325
+ );
275
326
  const hasPosition = matching.length > 0;
276
327
  if (expectedOpen && !hasPosition) throw new Error('Expected at least one matching open Perps position, but found none.');
277
328
  if (!expectedOpen && hasPosition) throw new Error(`Expected no matching open Perps positions, but found ${matching.length}.`);
@@ -280,8 +331,13 @@ export async function assertPositions(input, expectedOpen) {
280
331
 
281
332
  export async function assertOrders(input, expectedOpen) {
282
333
  requireExplicitSelection(input);
283
- const orders = await readOpenOrders(input);
284
- const matching = selectedItems(input, orders);
334
+ const timeoutMs = Number(input.node?.timeout_ms ?? 0);
335
+ const matching = await waitForSelectedState(
336
+ input,
337
+ readOpenOrders,
338
+ expectedOpen,
339
+ timeoutMs,
340
+ );
285
341
  const hasOrders = matching.length > 0;
286
342
  if (expectedOpen && !hasOrders) throw new Error('Expected at least one matching open Perps order, but found none.');
287
343
  if (!expectedOpen && hasOrders) throw new Error(`Expected no matching open Perps orders, but found ${matching.length}.`);
@@ -407,12 +463,43 @@ export async function placeOrder(input) {
407
463
  const symbol = marketSymbol(input);
408
464
  const side = String(input.node.side).toLowerCase();
409
465
  const amount = String(input.node.amount ?? input.node.notional);
410
- const size = String(input.node?.size ?? '0.0001');
411
466
  const leverage = Number(input.node?.leverage ?? 3);
412
467
  const maxSlippageBps = Number(input.node?.max_slippage_bps ?? input.node?.maxSlippageBps ?? 300);
413
468
  const isBuy = orderSideIsBuy(side);
469
+ const orderType = resolveOrderType(input);
414
470
  const currentPrice = await currentPriceForOrder(input, symbol);
415
- const result = await evalAsync(input, `Engine.context.PerpsController.placeOrder({ symbol: ${JSON.stringify(symbol)}, isBuy: ${JSON.stringify(isBuy)}, orderType: 'market', size: ${JSON.stringify(size)}, usdAmount: ${JSON.stringify(amount)}, leverage: ${JSON.stringify(leverage)}, currentPrice: ${JSON.stringify(currentPrice)}, maxSlippageBps: ${JSON.stringify(maxSlippageBps)} }).then(function(r){return JSON.stringify(r)})`);
471
+ const limitPrice = orderType === 'limit'
472
+ ? resolveLimitPrice(input, isBuy, currentPrice)
473
+ : null;
474
+ const size = String(
475
+ input.node?.size
476
+ ?? ((Number(amount) * leverage) / (limitPrice ?? currentPrice)),
477
+ );
478
+ const orderParams = orderType === 'limit'
479
+ ? {
480
+ symbol,
481
+ isBuy,
482
+ orderType,
483
+ size,
484
+ price: String(limitPrice),
485
+ timeInForce: 'GTC',
486
+ leverage,
487
+ currentPrice,
488
+ priceAtCalculation: currentPrice,
489
+ maxSlippageBps,
490
+ }
491
+ : {
492
+ symbol,
493
+ isBuy,
494
+ orderType,
495
+ size,
496
+ usdAmount: amount,
497
+ leverage,
498
+ currentPrice,
499
+ priceAtCalculation: currentPrice,
500
+ maxSlippageBps,
501
+ };
502
+ const result = await evalAsync(input, `Engine.context.PerpsController.placeOrder(${JSON.stringify(orderParams)}).then(function(r){return JSON.stringify(r)})`);
416
503
  if (result?.success === false || result == null) {
417
504
  throw new Error(`Failed to place ${symbol} ${side}: ${result?.error || JSON.stringify(result)}`);
418
505
  }
@@ -420,8 +507,12 @@ export async function placeOrder(input) {
420
507
  input,
421
508
  'globalThis.__AGENTIC__ && globalThis.__AGENTIC__.refreshPerpsStreams ? globalThis.__AGENTIC__.refreshPerpsStreams().then(function(r){return JSON.stringify(r)}) : Promise.resolve(JSON.stringify({ ok: false, reason: "refreshPerpsStreams unavailable" }))',
422
509
  );
423
- await waitForPositionPresent(input, symbol, Number(input.node?.timeout_ms ?? 30000));
424
- return { action: input.action, market: symbol, side, amount, size, leverage, submitted: true, result, refresh, proofPath: 'mobile-perps-controller-place-order' };
510
+ if (orderType === 'limit') {
511
+ await waitForSelectedState(input, readOpenOrders, true, Number(input.node?.timeout_ms ?? 30000));
512
+ } else {
513
+ await waitForPositionPresent(input, symbol, Number(input.node?.timeout_ms ?? 30000));
514
+ }
515
+ return { action: input.action, market: symbol, side, orderType, amount, size, leverage, limitPrice, submitted: true, result, refresh, proofPath: 'mobile-perps-controller-place-order' };
425
516
  }
426
517
 
427
518
  export async function ensurePositions(input) {
@@ -448,7 +539,14 @@ export async function ensureOrders(input) {
448
539
  const close = await closeOrders(input);
449
540
  return { ...(await assertOrders(input, false)), close };
450
541
  }
451
- if (state === 'open' || state === 'present') return assertOrders(input, true);
542
+ if (state === 'open' || state === 'present') {
543
+ const current = await readOpenOrders(input);
544
+ let order = null;
545
+ if (selectedItems(input, current).length === 0) {
546
+ order = await placeOrder({ ...input, node: { ...input.node, order_type: 'limit' } });
547
+ }
548
+ return { ...(await assertOrders(input, true)), order };
549
+ }
452
550
  throw new Error(`metamask.perps.ensure_orders received unsupported state: ${state}`);
453
551
  }
454
552
 
@@ -590,6 +590,15 @@
590
590
  "type": "string",
591
591
  "description": "EVM address to read positions for; defaults to the canonical wallet fixture account."
592
592
  },
593
+ "network": {
594
+ "type": "string",
595
+ "enum": [
596
+ "testnet",
597
+ "mainnet"
598
+ ],
599
+ "default": "testnet",
600
+ "description": "Controller network for this isolated Core read."
601
+ },
593
602
  "timeout_ms": {
594
603
  "type": "number"
595
604
  }
@@ -607,7 +616,7 @@
607
616
  }
608
617
  ],
609
618
  "proof_effect": "E2E validation must record this action in trace.json; live-proof actions include liveAdapter/proofPath or controller output.",
610
- "safety_notes": "Read-only HyperLiquid testnet HTTP query; must not inject controller/UI state to fabricate proof.",
619
+ "safety_notes": "Read-only HyperLiquid query on the explicitly selected network; must not inject controller/UI state to fabricate proof.",
611
620
  "execution_capabilities": []
612
621
  },
613
622
  {
@@ -697,6 +706,15 @@
697
706
  "type": "string",
698
707
  "description": "EVM address to read orders for; defaults to the canonical wallet fixture account."
699
708
  },
709
+ "network": {
710
+ "type": "string",
711
+ "enum": [
712
+ "testnet",
713
+ "mainnet"
714
+ ],
715
+ "default": "testnet",
716
+ "description": "Controller network for this isolated Core read."
717
+ },
700
718
  "timeout_ms": {
701
719
  "type": "number"
702
720
  }
@@ -714,7 +732,7 @@
714
732
  }
715
733
  ],
716
734
  "proof_effect": "E2E validation must record this action in trace.json; live-proof actions include liveAdapter/proofPath or controller output.",
717
- "safety_notes": "Read-only HyperLiquid testnet HTTP query; must not inject controller/UI state to fabricate proof.",
735
+ "safety_notes": "Read-only HyperLiquid query on the explicitly selected network; must not inject controller/UI state to fabricate proof.",
718
736
  "execution_capabilities": []
719
737
  },
720
738
  {
@@ -1101,6 +1119,15 @@
1101
1119
  "type": "string",
1102
1120
  "description": "EVM address to read for; defaults to the canonical wallet fixture account."
1103
1121
  },
1122
+ "network": {
1123
+ "type": "string",
1124
+ "enum": [
1125
+ "testnet",
1126
+ "mainnet"
1127
+ ],
1128
+ "default": "testnet",
1129
+ "description": "Controller network for this isolated Core assertion."
1130
+ },
1104
1131
  "timeout_ms": {
1105
1132
  "type": "number"
1106
1133
  }
@@ -1122,7 +1149,7 @@
1122
1149
  }
1123
1150
  ],
1124
1151
  "proof_effect": "E2E validation must record this action in trace.json; live-proof actions include liveAdapter/proofPath or controller output.",
1125
- "safety_notes": "Read-only HyperLiquid testnet HTTP query; must not inject controller state to fabricate proof.",
1152
+ "safety_notes": "Read-only HyperLiquid query on the explicitly selected network; must not inject controller state to fabricate proof.",
1126
1153
  "execution_capabilities": []
1127
1154
  },
1128
1155
  {
@@ -1458,6 +1485,15 @@
1458
1485
  ],
1459
1486
  "description": "Optional position/order side filter."
1460
1487
  },
1488
+ "network": {
1489
+ "type": "string",
1490
+ "enum": [
1491
+ "testnet",
1492
+ "mainnet"
1493
+ ],
1494
+ "default": "testnet",
1495
+ "description": "Controller network for this isolated Core assertion."
1496
+ },
1461
1497
  "timeout_ms": {
1462
1498
  "type": "number"
1463
1499
  },
@@ -1490,7 +1526,7 @@
1490
1526
  }
1491
1527
  ],
1492
1528
  "proof_effect": "E2E validation must record this action in trace.json; live-proof actions include liveAdapter/proofPath or target runtime output.",
1493
- "safety_notes": "Must not inject mid-recipe UI/app state to fabricate proof.",
1529
+ "safety_notes": "Read-only HyperLiquid query on the explicitly selected network; must not inject mid-recipe state to fabricate proof.",
1494
1530
  "execution_capabilities": []
1495
1531
  },
1496
1532
  {
@@ -782,7 +782,11 @@
782
782
  "type": "string"
783
783
  },
784
784
  "value": {
785
- "type": "string"
785
+ "type": [
786
+ "string",
787
+ "number"
788
+ ],
789
+ "description": "Text or a typed numeric recipe parameter; numbers are entered as their decimal text."
786
790
  },
787
791
  "text": {
788
792
  "type": "string"
@@ -2015,7 +2019,7 @@
2015
2019
  {
2016
2020
  "name": "metamask.perps.ensure_orders",
2017
2021
  "owner": "metamask",
2018
- "description": "extension Default to testnet for Perps mutations; mainnet is read-only unless explicitly requested. Higher-level wrapper that reads selected orders, cancels when needed, then asserts final order state.",
2022
+ "description": "extension Converge selected testnet orders to open or absent, then independently assert the final state. Creating an open state uses a resting limit order.",
2019
2023
  "schema": {
2020
2024
  "type": "object",
2021
2025
  "properties": {
@@ -2098,6 +2102,16 @@
2098
2102
  "timeout_ms": {
2099
2103
  "type": "number"
2100
2104
  },
2105
+ "notional": {
2106
+ "type": "number",
2107
+ "default": 10,
2108
+ "description": "Small testnet USD notional used only when creating a missing open order."
2109
+ },
2110
+ "leverage": {
2111
+ "type": "number",
2112
+ "default": 3,
2113
+ "description": "Testnet leverage used only when creating a missing open order."
2114
+ },
2101
2115
  "state": {
2102
2116
  "type": "string",
2103
2117
  "enum": [
@@ -2124,6 +2138,18 @@
2124
2138
  "mode": "all",
2125
2139
  "intent": "Converge Perps orders to the requested state"
2126
2140
  }
2141
+ },
2142
+ {
2143
+ "description": "Ensure one small resting ETH short order exists",
2144
+ "node": {
2145
+ "action": "metamask.perps.ensure_orders",
2146
+ "market": "ETH",
2147
+ "side": "short",
2148
+ "state": "open",
2149
+ "notional": 10,
2150
+ "leverage": 2,
2151
+ "intent": "Converge ETH open orders to one deterministic testnet precondition"
2152
+ }
2127
2153
  }
2128
2154
  ],
2129
2155
  "proof_effect": "E2E validation must record this action in trace.json; live-proof actions include liveAdapter/proofPath or target runtime output.",
@@ -316,6 +316,10 @@
316
316
  },
317
317
  "settle": {
318
318
  "type": "boolean"
319
+ },
320
+ "long_press": {
321
+ "type": "boolean",
322
+ "description": "Invoke the target component's long-press gesture. Requires identifier targeting."
319
323
  }
320
324
  },
321
325
  "additionalProperties": false
@@ -336,6 +340,15 @@
336
340
  "text": "Ethereum",
337
341
  "intent": "Press the component containing the visible Ethereum label"
338
342
  }
343
+ },
344
+ {
345
+ "description": "Long-press a component by stable React Native testID",
346
+ "node": {
347
+ "action": "ui.press",
348
+ "test_id": "keypad-delete-button",
349
+ "long_press": true,
350
+ "intent": "Clear the current keypad value through its human reset gesture"
351
+ }
339
352
  }
340
353
  ]
341
354
  },
@@ -2143,7 +2156,7 @@
2143
2156
  {
2144
2157
  "name": "metamask.perps.ensure_orders",
2145
2158
  "owner": "metamask",
2146
- "description": "mobile Default to testnet for Perps mutations; mainnet is read-only unless explicitly requested. Higher-level wrapper that reads selected orders, cancels when needed, then asserts final order state.",
2159
+ "description": "mobile Converge selected testnet orders to open or absent, then independently assert the final state. Creating an open state uses a resting limit order.",
2147
2160
  "schema": {
2148
2161
  "type": "object",
2149
2162
  "properties": {
@@ -2226,6 +2239,16 @@
2226
2239
  "timeout_ms": {
2227
2240
  "type": "number"
2228
2241
  },
2242
+ "notional": {
2243
+ "type": "number",
2244
+ "default": 10,
2245
+ "description": "Small testnet USD notional used only when creating a missing open order."
2246
+ },
2247
+ "leverage": {
2248
+ "type": "number",
2249
+ "default": 3,
2250
+ "description": "Testnet leverage used only when creating a missing open order."
2251
+ },
2229
2252
  "state": {
2230
2253
  "type": "string",
2231
2254
  "enum": [
@@ -2252,6 +2275,18 @@
2252
2275
  "mode": "all",
2253
2276
  "intent": "Converge Perps orders to the requested state"
2254
2277
  }
2278
+ },
2279
+ {
2280
+ "description": "Ensure one small resting ETH short order exists",
2281
+ "node": {
2282
+ "action": "metamask.perps.ensure_orders",
2283
+ "market": "ETH",
2284
+ "side": "short",
2285
+ "state": "open",
2286
+ "notional": 10,
2287
+ "leverage": 2,
2288
+ "intent": "Converge ETH open orders to one deterministic testnet precondition"
2289
+ }
2255
2290
  }
2256
2291
  ],
2257
2292
  "proof_effect": "E2E validation must record this action in trace.json; live-proof actions include liveAdapter/proofPath or target runtime output.",
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@deeeed/metamask-harness",
3
- "version": "0.19.0",
3
+ "version": "0.19.1",
4
4
  "type": "module",
5
5
  "bin": {
6
6
  "mm-harness": "bin/mm-harness"
@@ -21,7 +21,7 @@
21
21
  "dependencies": {
22
22
  "@farmslot/agent-runtime": "^0.2.0",
23
23
  "@farmslot/protocol": "^0.11.0",
24
- "@farmslot/recipe-harness": "^0.9.0",
24
+ "@farmslot/recipe-harness": "^0.9.1",
25
25
  "commander": "^12.0.0",
26
26
  "es-module-lexer": "2.3.1",
27
27
  "esbuild": "0.28.1",