@deeeed/metamask-harness 0.93.0 → 0.94.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,36 @@
2
2
 
3
3
  ## Unreleased
4
4
 
5
+ ## 0.94.1 - 2026-10-22
6
+
7
+ ### Fixed
8
+
9
+ - Mobile scroll-into-view checks the complete target bounds before treating it as already on screen.
10
+ - iOS simulator launch restarts app processes that have run for six hours or reached 1.5 GiB RSS before opening the dev client, including when the runtime otherwise looks ready.
11
+
12
+ ## 0.94.0 - 2026-10-22
13
+
14
+ ### Added
15
+
16
+ - Core `metamask.perps.place_order`, `ensure_positions` and `ensure_orders` take `margin_mode` (`isolated` or `cross`) with the same contract as Mobile: the controller order carries it, and the action proves the opened position's mode or, for a resting order, the venue lock (`getMarginModeLock`). It refuses a resting order before dispatch when the lock can't be read, and refuses anything but isolated on mainnet. `ensure_orders` proves reused orders per market and lists them in `marginModeLocks`; `margin_mode` applies only to `state=open`.
17
+ - Mobile and Core `metamask.perps.start_state` pass `margin_mode` through their `positions` and `orders` objects to the `ensure_*` step that creates the state.
18
+ - Mobile and Core positions report `side` from their signed size, and Core positions also report `marginMode`, like Mobile.
19
+ - On Android, an unlabeled test-ID container (for example a list row whose copy sits on child text views) reports its visible descendants' text as `descendant_text` (up to 500 characters), and `ui.locators` `byTestId` includes it in `description`.
20
+ - Mobile `ui.locators` takes `expect_enabled`, a map of requested test IDs to `true` or `false`, and re-reads until they reach that state or `timeout_ms` passes. The Pro margin chooser keeps Cross disabled until its venue lock read answers, so a single read can see a transient state. The last read is returned either way.
21
+
22
+ ### Changed
23
+
24
+ - The margin-mode request parsing and venue proofs live in one shared module (`library/actions/shared/perps/margin-mode.mjs`) used by Mobile and Core.
25
+ - Extension order actions don't take `margin_mode`: the Extension product has no Cross margin control to drive yet.
26
+
27
+ ### Fixed
28
+
29
+ - Mobile `place_order` with `margin_mode` retries the account-guarded position read on a transient client error instead of failing right after a fill. An account or network change is still never retried. A failed lock check now says the order may still be resting, since the order may have filled or been rejected. The lock mode is compared case-insensitively, and the returned top-level `marginMode` is lowercase like position reads; the `marginModeLock` objects keep the venue's value.
30
+ - Mobile `ui.locators` `test_ids` returns after one read when given no finite deadline instead of polling forever, and its description says to prove presence from `byTestId`.
31
+ - Mobile `ensure_orders` `margin_mode` documents `marginModeLocks`, one lock per selected market.
32
+ - Mobile Perps reads (positions, open orders, margin lock) retry a bridge read the app left unanswered (`CDP_TIMEOUT`, a busy JS thread) until their deadline, as they already did for `CLIENT_NOT_INITIALIZED`. A lost target or closed socket, as after a reload, still fails. Before, one busy moment right after a signed order failed `assert_positions` although the position opened.
33
+ - Mobile `start_state` with `page=false` keeps the current route even when `market` or `symbol` scopes the trading state. Before, it navigated to a `false` target before cleaning, so a cleanup could fail on navigation and leave the position open.
34
+
5
35
  ## 0.93.0 - 2026-10-21
6
36
 
7
37
  ### Added
@@ -127,6 +127,38 @@ function mobileSourceAwareLifecycleAdapters(lifecycle, readinessProbe = probeMob
127
127
  }
128
128
  }));
129
129
  }
130
+ function staleMobileAppProcessReason(usage) {
131
+ if (usage.elapsedSeconds !== void 0 && usage.elapsedSeconds >= 6 * 60 * 60) {
132
+ return `app process ${String(usage.pid)} has run for ${Math.floor(usage.elapsedSeconds / 60)} minutes`;
133
+ }
134
+ if (usage.residentBytes !== void 0 && usage.residentBytes >= 1.5 * 1024 ** 3) {
135
+ return `app process ${String(usage.pid)} uses ${Math.round(usage.residentBytes / 1024 ** 2)} MiB RSS`;
136
+ }
137
+ return void 0;
138
+ }
139
+ function readIosAppProcessUsage(env = process.env) {
140
+ const device = env.SIM_UDID || env.IOS_SIMULATOR || "booted";
141
+ const pid = readIosAppPid({ simulator: device }, { env });
142
+ if (pid === null) return {};
143
+ try {
144
+ const output = execFileSync("ps", ["-p", String(pid), "-o", "etime=,rss="], {
145
+ encoding: "utf8",
146
+ timeout: 5e3,
147
+ stdio: ["ignore", "pipe", "ignore"]
148
+ });
149
+ const row = output.trim().split("\n").at(-1) ?? "";
150
+ const match = /^(?:(\d+)-)?(?:(\d+):)?(\d+):(\d+)\s+(\d+)$/u.exec(row.trim());
151
+ if (!match) return { pid };
152
+ const [, days, hours, minutes, seconds, rssKiB] = match;
153
+ return {
154
+ pid,
155
+ elapsedSeconds: Number(days ?? 0) * 86400 + Number(hours ?? 0) * 3600 + Number(minutes) * 60 + Number(seconds),
156
+ residentBytes: Number(rssKiB) * 1024
157
+ };
158
+ } catch {
159
+ return { pid };
160
+ }
161
+ }
130
162
  function readAndroidAppPid(node, context) {
131
163
  const env = context.env ?? {};
132
164
  const device = node.adb_serial ?? node.android_device ?? node.device ?? env.ADB_SERIAL ?? env.ANDROID_SERIAL ?? process.env.ADB_SERIAL ?? process.env.ANDROID_SERIAL;
@@ -296,5 +328,7 @@ function asRecord(value) {
296
328
  }
297
329
  export {
298
330
  mobileSourceAwareLifecycleAdapters,
299
- resolveMobileLifecycleReadinessTimeout
331
+ readIosAppProcessUsage,
332
+ resolveMobileLifecycleReadinessTimeout,
333
+ staleMobileAppProcessReason
300
334
  };
@@ -567,7 +567,16 @@ async function queryMobileViewportTarget(input, testId) {
567
567
  "Mobile ui.scroll scroll_into_view requires the current __AGENTIC__.queryUiTarget bridge."
568
568
  );
569
569
  }
570
- return result;
570
+ const { rect, viewport } = result;
571
+ return {
572
+ ...result,
573
+ visible: result.visible === true && isRecord(rect) && isRecord(viewport) && scrollTargetFillsAxis(rect.x, rect.width, viewport.width) && scrollTargetFillsAxis(rect.y, rect.height, viewport.height)
574
+ };
575
+ }
576
+ function scrollTargetFillsAxis(start, size, viewport) {
577
+ if (typeof start !== "number" || typeof size !== "number" || typeof viewport !== "number" || !Number.isFinite(start) || !Number.isFinite(size) || !Number.isFinite(viewport) || size <= 0 || viewport <= 0) return false;
578
+ const visible = Math.min(start + size, viewport) - Math.max(start, 0);
579
+ return visible >= Math.min(size, viewport);
571
580
  }
572
581
  async function waitForMobileViewportTarget(input, testId, timeoutMs) {
573
582
  const deadline = Date.now() + Math.max(0, Math.min(timeoutMs, 5e3));
@@ -8,6 +8,7 @@ import { ensureHarnessFresh } from "../../adapters/harness-freshness.js";
8
8
  import { pinMobileFeatureFlags } from "../../adapters/mobile/feature-flags.js";
9
9
  import { toRemoteFlagSpec } from "../../feature-flags.js";
10
10
  import { clearMobileReleaseArtifactState } from "../../adapters/mobile/release-artifact-state.js";
11
+ import { readIosAppProcessUsage, staleMobileAppProcessReason } from "../../adapters/mobile/lifecycle.js";
11
12
  async function launchMobile(target, mobileTarget, tier, json, restartApp = false, clearMetro = false, remoteFlags) {
12
13
  clearMobileReleaseArtifactState(target);
13
14
  await ensureHarnessFresh(target, "mobile");
@@ -15,12 +16,15 @@ async function launchMobile(target, mobileTarget, tier, json, restartApp = false
15
16
  const watcherPort = process.env.WATCHER_PORT ? parseInt(process.env.WATCHER_PORT, 10) : void 0;
16
17
  const preflightMode = tier === "build" ? "rebuild-native" : "fast";
17
18
  const source = mobileSourceCheck(target);
19
+ const staleProcess = !restartApp && source.status === "current" && platform === "ios" ? staleMobileAppProcessReason(readIosAppProcessUsage()) : void 0;
20
+ if (staleProcess) process.stderr.write(`Mobile launch: ${staleProcess}; restarting the app process.
21
+ `);
18
22
  const result = await prepareMobile(target, {
19
23
  platform,
20
24
  json,
21
25
  watcherPort,
22
26
  preflightMode,
23
- restartApp: restartApp || source.status !== "current",
27
+ restartApp: restartApp || source.status !== "current" || staleProcess !== void 0,
24
28
  clearMetro
25
29
  });
26
30
  if (result.status !== 0) return result;
@@ -7,6 +7,7 @@ import { pathToFileURL } from 'node:url';
7
7
 
8
8
  import { walletFixturePath } from '../../harness-exports.mjs';
9
9
  import { normalizeSide, sideForItem } from '../../shared/perps/item-side.mjs';
10
+ import { marginModeOf } from '../../shared/perps/margin-mode.mjs';
10
11
  import { readFixtureAccounts } from '../../shared/wallet/fixture-accounts.mjs';
11
12
  import { createRewardsClient, rewardsApiUrl } from './_rewards.mjs';
12
13
 
@@ -655,8 +656,9 @@ export function redactPosition(position) {
655
656
  return {
656
657
  coin: position.coin ?? position.symbol ?? null,
657
658
  size: position.size ?? position.szi ?? null,
658
- side: position.side ?? null,
659
+ side: sideForItem(position, 'position') || normalizeSide(position.side) || null,
659
660
  entryPrice: position.entryPrice ?? position.entryPx ?? null,
661
+ marginMode: marginModeOf(position),
660
662
  };
661
663
  }
662
664
 
@@ -1,10 +1,11 @@
1
1
  // Converge selected testnet orders to open or absent state, then assert the result.
2
2
  // Reuse an existing matching order instead of placing another.
3
3
 
4
- import { getCoreController, isDirectRun, requireExplicitSelection, runAdapter, selectedItems } from './_controller.mjs';
4
+ import { getCoreController, getCoreControllerWithSigner, isDirectRun, requireExplicitSelection, runAdapter, selectedItems } from './_controller.mjs';
5
5
  import { assertOrders } from './assert_orders.mjs';
6
6
  import { closeOrders } from './close_orders.mjs';
7
7
  import { placeOrder } from './place_order.mjs';
8
+ import { refuseMarginModeForAbsentState, requireLockMarginMode, resolveMarginMode } from '../../shared/perps/margin-mode.mjs';
8
9
 
9
10
  export async function ensureOrders(input) {
10
11
  if (input.node?.state == null) throw new Error('metamask.perps.ensure_orders requires state=open or state=none.');
@@ -12,6 +13,7 @@ export async function ensureOrders(input) {
12
13
  const state = String(input.node.state).toLowerCase();
13
14
 
14
15
  if (state === 'none' || state === 'closed' || state === 'absent') {
16
+ refuseMarginModeForAbsentState(input.node, input.action);
15
17
  const close = await closeOrders(input);
16
18
  const assertion = await assertOrders(input, false);
17
19
  return { ...assertion, ensured: 'none', close };
@@ -28,11 +30,25 @@ export async function ensureOrders(input) {
28
30
  userAddress: accountAddress,
29
31
  });
30
32
  let order = null;
31
- if (selectedItems(input, orders).length === 0) {
33
+ const reused = selectedItems(input, orders);
34
+ if (reused.length === 0) {
32
35
  order = await placeOrder({ ...input, node: { ...input.node, order_type: 'limit' } });
33
36
  }
34
37
  const assertion = await assertOrders(input, true);
35
- return { ...assertion, ensured: 'open', order };
38
+ const marginMode = resolveMarginMode(input.node, input.action);
39
+ if (!marginMode) return { ...assertion, ensured: 'open', order };
40
+ // A placed order was already proved inside placeOrder; reused orders are proved per market.
41
+ const locks = {};
42
+ if (order) {
43
+ locks[order.market] = order.marginModeLock;
44
+ } else {
45
+ // The lock is read for the controller's own account, which only the signer setup binds.
46
+ const { controller: signed } = await getCoreControllerWithSigner(input);
47
+ for (const symbol of [...new Set(reused.map((item) => item.coin ?? item.symbol))]) {
48
+ locks[symbol] = requireLockMarginMode(input.action, symbol, await signed.getMarginModeLock({ symbol }), marginMode);
49
+ }
50
+ }
51
+ return { ...assertion, ensured: 'open', order, marginModeLock: Object.values(locks)[0], marginModeLocks: locks };
36
52
  }
37
53
 
38
54
  throw new Error(`metamask.perps.ensure_orders received unsupported state: ${state}`);
@@ -5,6 +5,7 @@ import { getCoreController, isDirectRun, requireExplicitSelection, runAdapter, s
5
5
  import { assertPositions } from './assert_positions.mjs';
6
6
  import { closePositions } from './close_positions.mjs';
7
7
  import { placeOrder } from './place_order.mjs';
8
+ import { refuseMarginModeForAbsentState, requirePositionMarginMode, resolveMarginMode } from '../../shared/perps/margin-mode.mjs';
8
9
 
9
10
  export async function ensurePositions(input) {
10
11
  if (input.node?.state == null) throw new Error('metamask.perps.ensure_positions requires state=open or state=none.');
@@ -12,6 +13,7 @@ export async function ensurePositions(input) {
12
13
  const state = String(input.node.state).toLowerCase();
13
14
 
14
15
  if (state === 'none' || state === 'closed' || state === 'absent') {
16
+ refuseMarginModeForAbsentState(input.node, input.action);
15
17
  const close = await closePositions(input);
16
18
  const assertion = await assertPositions(input, false);
17
19
  return { ...assertion, ensured: 'none', close };
@@ -25,7 +27,11 @@ export async function ensurePositions(input) {
25
27
  userAddress: accountAddress,
26
28
  });
27
29
  let order = null;
28
- if (selectedItems(input, positions, 'position').length === 0) {
30
+ const existing = selectedItems(input, positions, 'position');
31
+ const marginMode = resolveMarginMode(input.node, input.action);
32
+ // The venue cannot change an open position's mode, so a reused one must already hold it.
33
+ if (existing.length > 0 && marginMode) requirePositionMarginMode(input.action, existing, marginMode);
34
+ if (existing.length === 0) {
29
35
  order = await placeOrder(input);
30
36
  }
31
37
  const assertion = await assertPositions(input, true);
@@ -16,6 +16,12 @@ import {
16
16
  symbolForItem,
17
17
  } from './_controller.mjs';
18
18
  import { waitForPositionsAbsent } from './close_positions.mjs';
19
+ import {
20
+ requireLockMarginMode,
21
+ requireLockReadable,
22
+ requirePositionMarginMode,
23
+ resolveMarginMode,
24
+ } from '../../shared/perps/margin-mode.mjs';
19
25
  import {
20
26
  assertFlatBeforeRoundTrip,
21
27
  assertHouseLimitPosition,
@@ -376,6 +382,7 @@ export async function placeOrder(input) {
376
382
  }
377
383
  const isBuy = side === 'long';
378
384
  const orderType = resolveOrderType(input);
385
+ const marginMode = resolveMarginMode(input.node, input.action);
379
386
  const leverage = Number(optionalParam(input.node ?? {}, 'leverage') ?? 3);
380
387
  const maxSlippageBps = Number(
381
388
  optionalParam(
@@ -437,6 +444,7 @@ export async function placeOrder(input) {
437
444
  leverage,
438
445
  orderType,
439
446
  closeImmediately,
447
+ marginMode,
440
448
  projectRoot: input.context?.projectRoot,
441
449
  selectedAddress: signerAddress,
442
450
  });
@@ -489,6 +497,7 @@ export async function placeOrder(input) {
489
497
  const strayTimeInForce =
490
498
  orderType === 'limit' ? undefined : requestedTimeInForce;
491
499
  const forwardedFields = {
500
+ ...(marginMode ? { marginMode } : {}),
492
501
  ...(strayTriggerPrice === undefined
493
502
  ? {}
494
503
  : { triggerPrice: strayTriggerPrice }),
@@ -566,6 +575,11 @@ export async function placeOrder(input) {
566
575
  // close_immediately started from a flat market (checked above), so a position
567
576
  // in it after a failed placement is this order's: close it rather than leave it open.
568
577
  const restsOnBook = orderType === 'limit' || isTriggerOrderType(orderType);
578
+ if (marginMode && restsOnBook) {
579
+ requireLockReadable(input.action, symbol, await controller.getMarginModeLock({ symbol }));
580
+ }
581
+ let marginModeLock;
582
+ let observedMarginMode;
569
583
  const placeAndVerify = async () => {
570
584
  const result = await controller.placeOrder(orderParams);
571
585
  if (!result || result.success !== true) {
@@ -614,6 +628,13 @@ export async function placeOrder(input) {
614
628
  `core placeOrder (market) for ${symbol} reported success but no matching position is visible (orderId=${result.orderId ?? 'null'}).`,
615
629
  );
616
630
  }
631
+ if (marginMode && restsOnBook) {
632
+ const resting = ` The ${symbol} order it placed (${result.orderId ?? 'unknown id'}) may still be resting; cancel it with ensure_orders state=none.`;
633
+ marginModeLock = requireLockMarginMode(input.action, symbol, await controller.getMarginModeLock({ symbol }), marginMode, resting);
634
+ observedMarginMode = String(marginModeLock.marginMode).toLowerCase();
635
+ } else if (marginMode) {
636
+ observedMarginMode = requirePositionMarginMode(input.action, matchingPositions, marginMode);
637
+ }
617
638
  return { result, matchingOrders, matchingPositions };
618
639
  };
619
640
  let placed;
@@ -649,6 +670,8 @@ export async function placeOrder(input) {
649
670
  orderType,
650
671
  notional: usdAmount,
651
672
  leverage,
673
+ ...(observedMarginMode ? { marginMode: observedMarginMode } : {}),
674
+ ...(marginModeLock ? { marginModeLock } : {}),
652
675
  timeInForce,
653
676
  size: orderParams.size,
654
677
  currentPrice,
@@ -1,8 +1,17 @@
1
1
  // Implement Mobile Perps navigation, trading mutations, assertions, and setup or cleanup through the bridge.
2
2
  // Strategy getters used during cleanup can settle tracked collateral.
3
- import { sideForItem } from '../../shared/perps/item-side.mjs';
3
+ import { normalizeSide, sideForItem } from '../../shared/perps/item-side.mjs';
4
+ import {
5
+ marginModeOf,
6
+ refuseMarginModeForAbsentState,
7
+ requireLockMarginMode,
8
+ requireLockReadable,
9
+ requirePositionMarginMode,
10
+ resolveMarginMode,
11
+ } from '../../shared/perps/margin-mode.mjs';
4
12
  import { pathToFileURL } from 'node:url';
5
13
  import {
14
+ MOBILE_BRIDGE_ERROR_CODES,
6
15
  MOBILE_BRIDGE_RESULT_ERROR_CODES,
7
16
  bridgeCommand,
8
17
  createSetupBudget,
@@ -251,8 +260,10 @@ export async function ensureMode(input) {
251
260
  throw new Error(`Perps did not reach ${requestedMode} mode before timeout.`);
252
261
  }
253
262
 
254
- // Retries the client-not-initialized race until the deadline, and a 429 with
255
- // the shared rate-limit backoff.
263
+ // Retries the client-not-initialized race and a CDP_TIMEOUT (the app's JS
264
+ // thread busy, e.g. signing on a dev build) until the deadline, and a 429 with
265
+ // the shared rate-limit backoff. Only reads are wrapped, so a retry cannot
266
+ // repeat a write. A lost target or closed socket (a reload) still throws.
256
267
  export async function retryPerpsClientRead(
257
268
  read,
258
269
  {
@@ -274,7 +285,8 @@ export async function retryPerpsClientRead(
274
285
  try {
275
286
  return await withRateLimitRetry(call, read, { deadline, sleep: wait, random });
276
287
  } catch (error) {
277
- if (!/CLIENT_NOT_INITIALIZED/u.test(String(error?.message ?? error))) {
288
+ if (!/CLIENT_NOT_INITIALIZED/u.test(String(error?.message ?? error))
289
+ && error?.code !== MOBILE_BRIDGE_ERROR_CODES.CDP_TIMEOUT) {
278
290
  throw error;
279
291
  }
280
292
  const remainingMs = deadline - Date.now();
@@ -349,17 +361,6 @@ function resolveOrderType(input) {
349
361
  return value;
350
362
  }
351
363
 
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
-
363
364
  function resolveLimitPrice(input, isBuy, currentPrice) {
364
365
  const explicit = input.node?.limit_price ?? input.node?.limitPrice ?? input.node?.price;
365
366
  if (explicit != null && String(explicit).length > 0) {
@@ -458,30 +459,7 @@ async function readMarginModeLock(input, symbol) {
458
459
  }
459
460
 
460
461
  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
- }
462
+ return requireLockMarginMode(input.action, symbol, await readMarginModeLock(input, symbol), marginMode, leftOver);
485
463
  }
486
464
 
487
465
  const TWAP_NOT_ADVERTISED = 'provider-does-not-advertise-twap';
@@ -885,10 +863,9 @@ function redactPosition(position) {
885
863
  return {
886
864
  symbol: position.symbol ?? position.coin ?? null,
887
865
  size: position.size ?? position.szi ?? null,
888
- side: position.side ?? null,
866
+ side: sideForItem(position, 'position') || normalizeSide(position.side) || null,
889
867
  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,
868
+ marginMode: marginModeOf(position),
892
869
  };
893
870
  }
894
871
 
@@ -1585,7 +1562,7 @@ export async function placeOrder(input, { ensure = false } = {}) {
1585
1562
  const maxSlippageBps = Number(input.node?.max_slippage_bps ?? input.node?.maxSlippageBps ?? 300);
1586
1563
  const isBuy = orderSideIsBuy(side);
1587
1564
  const orderType = resolveOrderType(input);
1588
- const marginMode = resolveMarginMode(input);
1565
+ const marginMode = resolveMarginMode(input.node, input.action);
1589
1566
  const closeImmediately = closeImmediatelyRequested(input.node);
1590
1567
  const runtime = await readPerpsRuntimeState(input);
1591
1568
  const network = orderNetwork({ declared: input.node?.network, liveIsTestnet: runtime?.isTestnet });
@@ -1660,7 +1637,7 @@ export async function placeOrder(input, { ensure = false } = {}) {
1660
1637
  maxSlippageBps,
1661
1638
  ...(marginMode ? { marginMode } : {}),
1662
1639
  };
1663
- if (marginMode && orderType === 'limit') await requireMarginModeLockReadable(input, symbol);
1640
+ if (marginMode && orderType === 'limit') requireLockReadable(input.action, symbol, await readMarginModeLock(input, symbol));
1664
1641
  if (await tradingContextChanged(input, bound)) {
1665
1642
  throw new Error('Perps order not dispatched: the selected account or network changed after the order rule allowed it.');
1666
1643
  }
@@ -1712,13 +1689,16 @@ export async function placeOrder(input, { ensure = false } = {}) {
1712
1689
  await waitForPositionPresent(input, symbol, Number(input.node?.timeout_ms ?? 30000));
1713
1690
  }
1714
1691
  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'}.`;
1692
+ const resting = ` The ${symbol} order it placed (${result?.orderId ?? 'unknown id'}) may still be resting; ${ledgerId ? "the run's teardown cancels it" : 'cancel it with ensure_orders state=none'}.`;
1716
1693
  marginModeLock = await requireOrderMarginMode(input, symbol, marginMode, resting);
1717
- observedMarginMode = marginModeLock.marginMode;
1694
+ observedMarginMode = String(marginModeLock.marginMode).toLowerCase();
1718
1695
  } 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;
1696
+ const positions = await retryPerpsClientRead(
1697
+ () => readBoundPositions(input, bound),
1698
+ { deadlineEpochMs: inheritedDeadline(input), call: 'PerpsController.getPositions' },
1699
+ );
1700
+ const opened = positions.filter((position) => symbolForItem(position) === symbol);
1701
+ observedMarginMode = requirePositionMarginMode(input.action, opened, marginMode);
1722
1702
  }
1723
1703
  } catch (error) {
1724
1704
  if (ledgerId && error?.knownNotDispatched) await dropMainnetOrder(input.context.projectRoot, ledgerId);
@@ -1894,7 +1874,7 @@ export async function ensurePositions(input) {
1894
1874
  requireExplicitSelection(input);
1895
1875
  const state = String(input.node.state).toLowerCase();
1896
1876
  if (state === 'none' || state === 'closed' || state === 'absent') {
1897
- refuseMarginModeForAbsentState(input);
1877
+ refuseMarginModeForAbsentState(input.node, input.action);
1898
1878
  const close = await closePositions(input);
1899
1879
  return { ...(await assertPositions(input, false)), close };
1900
1880
  }
@@ -1902,8 +1882,8 @@ export async function ensurePositions(input) {
1902
1882
  const existing = await readPositions(input);
1903
1883
  if (selectedItems(input, existing).length === 0) await placeOrder(input, { ensure: true });
1904
1884
  const result = await assertPositions(input, true);
1905
- const marginMode = resolveMarginMode(input);
1906
- if (marginMode) requirePositionMarginMode(input, result.positions, marginMode);
1885
+ const marginMode = resolveMarginMode(input.node, input.action);
1886
+ if (marginMode) requirePositionMarginMode(input.action, result.positions, marginMode);
1907
1887
  return result;
1908
1888
  }
1909
1889
  throw new Error(`metamask.perps.ensure_positions received unsupported state: ${state}`);
@@ -1914,7 +1894,7 @@ export async function ensureOrders(input) {
1914
1894
  requireExplicitSelection(input);
1915
1895
  const state = String(input.node.state).toLowerCase();
1916
1896
  if (state === 'none' || state === 'closed' || state === 'absent') {
1917
- refuseMarginModeForAbsentState(input);
1897
+ refuseMarginModeForAbsentState(input.node, input.action);
1918
1898
  const close = await closeOrders(input);
1919
1899
  return { ...(await assertOrders(input, false)), close };
1920
1900
  }
@@ -1925,7 +1905,7 @@ export async function ensureOrders(input) {
1925
1905
  order = await placeOrder({ ...input, node: { ...input.node, order_type: 'limit' } }, { ensure: true });
1926
1906
  }
1927
1907
  const result = await assertOrders(input, true);
1928
- const marginMode = resolveMarginMode(input);
1908
+ const marginMode = resolveMarginMode(input.node, input.action);
1929
1909
  if (!marginMode) return { ...result, order };
1930
1910
  // A placed order was already proved inside placeOrder; reused orders are proved per market.
1931
1911
  const symbols = order ? [marketSymbol(input)] : [...new Set(result.orders.map((item) => item.symbol))];
@@ -1939,12 +1919,6 @@ export async function ensureOrders(input) {
1939
1919
  }
1940
1920
 
1941
1921
 
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
-
1948
1922
  function paramsForState(input) {
1949
1923
  const nested = input.node?.params && typeof input.node.params === 'object' ? input.node.params : {};
1950
1924
  return { ...nested, ...input.node };
@@ -2122,8 +2096,10 @@ async function applyStrategiesState(input, config) {
2122
2096
  return closeStrategyOrders(childInput(input, node));
2123
2097
  }
2124
2098
 
2125
- async function applyStateNavigation(input, config) {
2126
- if (!config.page && !config.market && !config.symbol) return { skipped: true };
2099
+ // page=false preserves the current route even when market/symbol scopes the
2100
+ // cleanup, so a teardown cannot fail on navigation before it cleans.
2101
+ export async function applyStateNavigation(input, config) {
2102
+ if (config.page === false || (!config.page && !config.market && !config.symbol)) return { skipped: true };
2127
2103
  const target = config.page ?? defaultNavigationTarget(config);
2128
2104
  return navigatePerps(childInput(input, { target, market: config.market, symbol: config.symbol }));
2129
2105
  }
@@ -503,18 +503,26 @@ function flattenIosHierarchy(value, textTestIds = [], hudIntent) {
503
503
  return { visible, hidden, nodeCount, screenName };
504
504
  }
505
505
 
506
- function parseAndroidHierarchy(output, hudIntent) {
506
+ // Unlabeled test-ID containers (a Pressable around TextViews) also report the
507
+ // visible text of their descendants as descendant_text, capped so a container
508
+ // such as a scroll view does not carry the whole screen.
509
+ const DESCENDANT_TEXT_LIMIT = 500;
510
+
511
+ export function parseAndroidHierarchy(output, hudIntent) {
507
512
  const xmlStart = Math.max(output.indexOf('<?xml'), output.indexOf('<hierarchy'));
508
513
  const xml = xmlStart >= 0 ? output.slice(xmlStart) : output;
509
514
  const visible = [];
510
515
  const hidden = [];
511
516
  const ancestorVisibility = [];
517
+ const openItems = [];
512
518
  let nodeCount = 0;
513
519
  let screenName;
514
520
  for (const match of xml.matchAll(/<\/?node\b[^>]*>/gu)) {
515
521
  const tag = match[0];
516
522
  if (tag.startsWith('</')) {
517
523
  ancestorVisibility.pop();
524
+ const closed = openItems.pop();
525
+ if (closed?.texts.length) closed.item.descendant_text = closed.texts.join(' ').slice(0, DESCENDANT_TEXT_LIMIT);
518
526
  continue;
519
527
  }
520
528
  const attributes = {};
@@ -528,6 +536,7 @@ function parseAndroidHierarchy(output, hudIntent) {
528
536
  const label = text(attributes['content-desc']) ??
529
537
  (isEditable ? undefined : text(attributes.text));
530
538
  if (!screenName && label && !isEditable) screenName = label;
539
+ const ownText = isEditable ? undefined : text(attributes.text) ?? text(attributes['content-desc']);
531
540
  const item = compactItem({
532
541
  role,
533
542
  label,
@@ -540,6 +549,9 @@ function parseAndroidHierarchy(output, hudIntent) {
540
549
  const isVisible =
541
550
  ancestorVisibility.every(Boolean) &&
542
551
  attributes['visible-to-user'] !== 'false';
552
+ if (ownText && isVisible) {
553
+ for (const open of openItems) if (open) open.texts.push(ownText);
554
+ }
543
555
  const isActionable =
544
556
  isActionableRole(role) ||
545
557
  attributes.clickable === 'true' ||
@@ -551,7 +563,10 @@ function parseAndroidHierarchy(output, hudIntent) {
551
563
  hidden.push({ ...item, reason: 'hidden' });
552
564
  }
553
565
  }
554
- if (!tag.endsWith('/>')) ancestorVisibility.push(isVisible);
566
+ if (!tag.endsWith('/>')) {
567
+ ancestorVisibility.push(isVisible);
568
+ openItems.push(item.test_id && !item.label ? { item, texts: [] } : null);
569
+ }
555
570
  }
556
571
  return { visible, hidden, nodeCount, screenName };
557
572
  }
@@ -4,6 +4,7 @@ import { earliestDeadline, runAdapter } from '../platform/bridge.mjs';
4
4
  import {
5
5
  locatorsFromObservation,
6
6
  observeRequestedTestIds,
7
+ requestedEnabledStates,
7
8
  requestedTestIds,
8
9
  requestedTimeoutMs,
9
10
  requireVisibleObservation,
@@ -18,6 +19,7 @@ runAdapter(async (input) => {
18
19
  )),
19
20
  testIds,
20
21
  earliestDeadline(input, Date.now() + requestedTimeoutMs(input.node)),
22
+ requestedEnabledStates(input.node, testIds),
21
23
  );
22
24
  return {
23
25
  action: input.action,
@@ -0,0 +1,53 @@
1
+ // Margin-mode requests and venue proofs shared by the Mobile and Core Perps order actions.
2
+
3
+ export const MARGIN_MODES = Object.freeze(['isolated', 'cross']);
4
+
5
+ // Omitted keeps the venue default: the open position's mode, else isolated.
6
+ export function resolveMarginMode(node, action) {
7
+ const raw = node?.margin_mode ?? node?.marginMode;
8
+ if (raw == null || raw === '') return undefined;
9
+ const value = String(raw).toLowerCase();
10
+ if (!MARGIN_MODES.includes(value)) {
11
+ throw new Error(`${action} received unsupported margin_mode: ${value}.`);
12
+ }
13
+ return value;
14
+ }
15
+
16
+ // Same source as the mainnet house-limit check, normalized to isolated|cross.
17
+ // Accepts a raw controller position or one already redacted with marginMode.
18
+ export function marginModeOf(position) {
19
+ return String(position?.marginMode ?? position?.leverage?.type ?? position?.marginType ?? '').toLowerCase() || null;
20
+ }
21
+
22
+ export function refuseMarginModeForAbsentState(node, action) {
23
+ if (resolveMarginMode(node, action)) {
24
+ throw new Error(`${action} margin_mode applies only to state=open; drop it for state=none.`);
25
+ }
26
+ }
27
+
28
+ export function requirePositionMarginMode(action, positions, marginMode) {
29
+ if (positions.length === 0) {
30
+ throw new Error(`${action} requested margin_mode=${marginMode}, but no matching position was observed to prove it.`);
31
+ }
32
+ const mismatched = positions.filter((position) => marginModeOf(position) !== marginMode);
33
+ if (mismatched.length > 0) {
34
+ const found = mismatched.map((position) => `${position.symbol ?? position.coin} is ${marginModeOf(position)}`).join(', ');
35
+ throw new Error(`${action} requested margin_mode=${marginMode}, but ${found}; the venue cannot change an open position's mode, so converge with state=none first.`);
36
+ }
37
+ return marginModeOf(positions[0]);
38
+ }
39
+
40
+ // A resting order is only provable through the venue lock, so refuse before
41
+ // dispatch when the build or provider cannot answer it.
42
+ export function requireLockReadable(action, symbol, lock) {
43
+ if (lock?.status !== 'locked' && lock?.status !== 'unlocked') {
44
+ throw new Error(`${action} cannot verify margin_mode for ${symbol} before placing a resting order: the venue lock reports ${JSON.stringify(lock)}.`);
45
+ }
46
+ }
47
+
48
+ export function requireLockMarginMode(action, symbol, lock, marginMode, leftOver = '') {
49
+ if (lock?.status !== 'locked' || String(lock.marginMode).toLowerCase() !== marginMode) {
50
+ throw new Error(`${action} requested margin_mode=${marginMode} for ${symbol}, but the venue reports ${JSON.stringify(lock)}.${leftOver}`);
51
+ }
52
+ return lock;
53
+ }
@@ -77,15 +77,20 @@ function visibleStateByTestId(items, testIds, truncated) {
77
77
  }
78
78
 
79
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) {
80
+ // in, and a control can stay disabled until its data loads, so requested test
81
+ // IDs are re-read until observed (and in their expected enabled state) or the
82
+ // bound expires. The caller asserts on whatever the last read saw.
83
+ export async function observeRequestedTestIds(observe, testIds, deadlineEpochMs, expectEnabled = {}) {
82
84
  const pollMs = 250;
83
85
  while (true) {
84
86
  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;
87
+ if (testIds.length === 0 || !Number.isFinite(deadlineEpochMs)) return observation;
88
+ const items = (Array.isArray(observation?.items) ? observation.items : []).map(record);
89
+ const settled = testIds.every((testId) => {
90
+ const item = items.find((candidate) => text(candidate.test_id) === testId);
91
+ return item && (!(testId in expectEnabled) || item.enabled === expectEnabled[testId]);
92
+ });
93
+ if (settled) return observation;
89
94
  if (Date.now() + pollMs >= deadlineEpochMs) return observation;
90
95
  await new Promise((resolve) => setTimeout(resolve, pollMs));
91
96
  }
@@ -100,6 +105,24 @@ export function requestedTestIds(node) {
100
105
  return value.map((id) => id.trim());
101
106
  }
102
107
 
108
+ // Booleans may arrive as rendered template strings ("true"/"false").
109
+ export function requestedEnabledStates(node, testIds) {
110
+ const value = node?.expect_enabled;
111
+ if (value == null) return {};
112
+ if (typeof value !== 'object' || Array.isArray(value)) {
113
+ throw new Error('ui.locators expect_enabled must map requested test IDs to true or false.');
114
+ }
115
+ const states = Object.create(null);
116
+ for (const [testId, raw] of Object.entries(value)) {
117
+ const enabled = raw === true || raw === 'true' ? true : raw === false || raw === 'false' ? false : null;
118
+ if (!testIds.includes(testId) || enabled === null) {
119
+ throw new Error(`ui.locators expect_enabled must map requested test IDs to true or false; got ${testId}: ${JSON.stringify(raw)}.`);
120
+ }
121
+ states[testId] = enabled;
122
+ }
123
+ return states;
124
+ }
125
+
103
126
  export function requestedTimeoutMs(node) {
104
127
  const value = node?.timeout_ms;
105
128
  if (value == null) return 5000;
@@ -189,6 +212,7 @@ function supportsTextTarget(role) {
189
212
  function describe(item) {
190
213
  const parts = [text(item.role) ?? 'element'];
191
214
  if (text(item.label)) parts.push(`label=${JSON.stringify(text(item.label))}`);
215
+ if (text(item.descendant_text)) parts.push(`text=${JSON.stringify(text(item.descendant_text))}`);
192
216
  if (text(item.test_id)) parts.push(`test_id=${JSON.stringify(text(item.test_id))}`);
193
217
  return parts.join(' ');
194
218
  }
@@ -572,7 +572,7 @@
572
572
  "execution_capabilities": []
573
573
  },
574
574
  "metamask.perps.read_positions": {
575
- "description": "core Read live Perps positions directly from the Perps controller; without a selector, return all live positions.",
575
+ "description": "core Read live Perps positions directly from the Perps controller; without a selector, return all live positions. Each position reports side (from its signed size) and marginMode (isolated or cross) when the controller exposes it.",
576
576
  "schema": {
577
577
  "type": "object",
578
578
  "properties": {
@@ -1098,6 +1098,11 @@
1098
1098
  "type": "number",
1099
1099
  "default": 3
1100
1100
  },
1101
+ "margin_mode": {
1102
+ "type": "string",
1103
+ "enum": ["isolated", "cross"],
1104
+ "description": "Collateral mode for this order (Hyperliquid). The action proves the opened position or, for a resting order, the venue lock carries it and fails otherwise. Mainnet refuses anything but isolated. Omitted keeps the venue default: the open position mode, else isolated."
1105
+ },
1101
1106
  "close_immediately": {
1102
1107
  "type": "boolean",
1103
1108
  "description": "Close the opened position again inside this action and prove the market flat (market orders only; the market must start flat). Required on mainnet, with leverage 1 and notional at most $15 from a fixture account."
@@ -1701,6 +1706,11 @@
1701
1706
  ],
1702
1707
  "description": "USD notional alias."
1703
1708
  },
1709
+ "margin_mode": {
1710
+ "type": "string",
1711
+ "enum": ["isolated", "cross"],
1712
+ "description": "Collateral mode for a created position. A reused open position must already hold it, since the venue cannot change it while open. Applies only to state=open."
1713
+ },
1704
1714
  "leverage": {
1705
1715
  "type": "number"
1706
1716
  },
@@ -2188,6 +2198,11 @@
2188
2198
  "type": "number",
2189
2199
  "description": "Leverage used only when creating a missing open order."
2190
2200
  },
2201
+ "margin_mode": {
2202
+ "type": "string",
2203
+ "enum": ["isolated", "cross"],
2204
+ "description": "Collateral mode for a created resting order. Also proves the venue lock binds this mode on every selected market, including orders that already rested; the result lists each lock in marginModeLocks. Applies only to state=open."
2205
+ },
2191
2206
  "state": {
2192
2207
  "type": "string",
2193
2208
  "enum": [
@@ -2347,6 +2362,11 @@
2347
2362
  "leverage": {
2348
2363
  "type": "number"
2349
2364
  },
2365
+ "margin_mode": {
2366
+ "type": "string",
2367
+ "enum": ["isolated", "cross"],
2368
+ "description": "Collateral mode passed to the ensure_* step that creates this state (isolated or cross); see metamask.perps.ensure_positions / ensure_orders."
2369
+ },
2350
2370
  "order_type": {
2351
2371
  "type": "string"
2352
2372
  },
@@ -2458,6 +2478,11 @@
2458
2478
  "leverage": {
2459
2479
  "type": "number"
2460
2480
  },
2481
+ "margin_mode": {
2482
+ "type": "string",
2483
+ "enum": ["isolated", "cross"],
2484
+ "description": "Collateral mode passed to the ensure_* step that creates this state (isolated or cross); see metamask.perps.ensure_positions / ensure_orders."
2485
+ },
2461
2486
  "order_type": {
2462
2487
  "type": "string"
2463
2488
  },
@@ -1009,12 +1009,16 @@
1009
1009
  "test_ids": {
1010
1010
  "type": "array",
1011
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."
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 accessibility observation itself was truncated (not the 20-entry ranked list) and cannot prove absence. Prove presence from byTestId, not from an empty missingTestIds. Use it to assert control state; locators lists only press targets."
1013
+ },
1014
+ "expect_enabled": {
1015
+ "type": "object",
1016
+ "description": "Map of requested test IDs to the enabled state (true or false) to wait for, for controls that stay disabled until their data loads. The last read is returned either way; assert on byTestId."
1013
1017
  },
1014
1018
  "timeout_ms": {
1015
1019
  "type": "number",
1016
1020
  "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)."
1021
+ "description": "With test_ids, re-read the observation until every requested test ID is present (and matches expect_enabled) or this many milliseconds pass (default 5000)."
1018
1022
  }
1019
1023
  },
1020
1024
  "additionalProperties": false
@@ -2374,7 +2378,7 @@
2374
2378
  "execution_capabilities": ["app-mutation"]
2375
2379
  },
2376
2380
  "metamask.perps.read_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.",
2381
+ "description": "mobile Read live Perps positions; without a selector, return all live positions. Each position reports side (from its signed size) and marginMode (isolated or cross) when the product exposes it.",
2378
2382
  "schema": {
2379
2383
  "type": "object",
2380
2384
  "properties": {
@@ -3273,7 +3277,7 @@
3273
3277
  "margin_mode": {
3274
3278
  "type": "string",
3275
3279
  "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."
3280
+ "description": "Collateral mode for a created resting order. Also proves the venue lock binds this mode on every selected market, including orders that already rested; the result lists each lock in marginModeLocks, and marginModeLock is the first market's. Requires a build with getMarginModeLock."
3277
3281
  },
3278
3282
  "state": {
3279
3283
  "type": "string",
@@ -3582,7 +3586,7 @@
3582
3586
  }
3583
3587
  },
3584
3588
  "page": {
3585
- "description": "Initial Perps page target; omit it to use the profile or market default, or set false without market/symbol to preserve the current route.",
3589
+ "description": "Initial Perps page target; omit it to use the profile or market default, or set false to preserve the current route (market/symbol then only scope the trading state). Without navigation, Perps must already have been opened in this app session.",
3586
3590
  "type": ["string", "boolean"],
3587
3591
  "enum": [false, "home", "perps", "perps_home", "market", "market_details"]
3588
3592
  },
@@ -3642,6 +3646,11 @@
3642
3646
  "leverage": {
3643
3647
  "type": "number"
3644
3648
  },
3649
+ "margin_mode": {
3650
+ "type": "string",
3651
+ "enum": ["isolated", "cross"],
3652
+ "description": "Collateral mode passed to the ensure_* step that creates this state (isolated or cross); see metamask.perps.ensure_positions / ensure_orders."
3653
+ },
3645
3654
  "order_type": {
3646
3655
  "type": "string"
3647
3656
  },
@@ -3730,6 +3739,11 @@
3730
3739
  "leverage": {
3731
3740
  "type": "number"
3732
3741
  },
3742
+ "margin_mode": {
3743
+ "type": "string",
3744
+ "enum": ["isolated", "cross"],
3745
+ "description": "Collateral mode passed to the ensure_* step that creates this state (isolated or cross); see metamask.perps.ensure_positions / ensure_orders."
3746
+ },
3733
3747
  "order_type": {
3734
3748
  "type": "string"
3735
3749
  },
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@deeeed/metamask-harness",
3
- "version": "0.93.0",
3
+ "version": "0.94.1",
4
4
  "type": "module",
5
5
  "bin": {
6
6
  "mm-harness": "bin/mm-harness"