@deeeed/metamask-harness 0.86.0 → 0.86.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,14 @@
2
2
 
3
3
  ## Unreleased
4
4
 
5
+ ## 0.86.1 - 2026-10-09
6
+
7
+ ### Fixed
8
+
9
+ - Extension `metamask.perps.close_visible_position cleanup_only=true` reconciles the final balance against the margin the close releases. With no placement receipt, the baseline is the available balance read while the position is still open. That baseline excludes the position's margin, so a clean full close went over the $1 tolerance and left an `outcome-unknown` receipt. A 0.11 SOL testnet close moved $524.42 to $536.45 and failed this way. The action now reads the close modal's "You'll receive" amount before dispatch and records it in the receipt as `closeReleaseBaseUnits`. It then checks the final balance against baseline + receive, with the same tolerance. If the amount is not visible, the action refuses before publishing a receipt or clicking. Closes backed by a placement receipt still reconcile against the pre-order balance. Cleanup output adds `releaseBaseUnits` and `expectedBalanceAfterBaseUnits`.
10
+ - Mobile `metamask.perps.read_visible_state` on iOS sees Perps Text test IDs. iOS accessibility lists React Native Text as a StaticText with no identifier, so a visible Lite market price (`perps-market-header-price`) failed `require_features: ["price"]`. On a non-opaque iOS run, `ui.visible` now reads each mounted `perps-` Text's test ID, string and size over the dev bridge. It gives that test ID to the on-screen StaticText with the same label and size, when exactly one test ID matches. Offscreen StaticText stays unbound, and so does a same-text twin of another size (the collapsed header price). Other Text, such as a revealed Secret Recovery Phrase word, is not read. This reaches every iOS `ui.visible` consumer: `ui.locators`, native-navigation test-ID waits, `readVisiblePerpsTestIds`, `configure_order`, and the assets and swap-bridge `read_visible_state`. Opaque runtimes and Android are unchanged. A non-opaque run with Metro down pays one bridge attempt (at most 2 s) per observation and gets a `ui.visible` warning; a read over 500 Texts is truncated with a warning.
11
+ - An empty `METAMASK_RECIPE_MOBILE_BRIDGE_SCRIPT` means the bundled `cdp-bridge.cjs` everywhere the TypeScript side spawns the Mobile bridge, as `run` and the action library already treated it. `launch`'s feature-flag reads and pins (and `doctor`'s) and the `status` live probe read the variable with `??`. An empty value made them check for a script named `""`, so they reported no runtime and never spawned a bridge. All TypeScript callers now share one `mobileBridgeScript()`; the action library keeps the same rule in its own `bridgeScript()` because it cannot import `src/`.
12
+
5
13
  ## 0.86.0 - 2026-10-09
6
14
 
7
15
  ### Added
@@ -0,0 +1,12 @@
1
+ import path from "node:path";
2
+ import { runnerDir } from "../../paths.js";
3
+ function mobileBridgeScriptOverride() {
4
+ return process.env.METAMASK_RECIPE_MOBILE_BRIDGE_SCRIPT || void 0;
5
+ }
6
+ function mobileBridgeScript() {
7
+ return mobileBridgeScriptOverride() ?? path.join(runnerDir, "adapters", "mobile", "bridge-runtime", "cdp-bridge.cjs");
8
+ }
9
+ export {
10
+ mobileBridgeScript,
11
+ mobileBridgeScriptOverride
12
+ };
@@ -1,11 +1,7 @@
1
1
  import { spawn } from "node:child_process";
2
2
  import fs from "node:fs";
3
- import path from "node:path";
4
- import { runnerDir } from "../../paths.js";
3
+ import { mobileBridgeScript } from "./bridge-script.js";
5
4
  const DEFAULT_TIMEOUT_MS = 1e4;
6
- function bridgeScriptPath() {
7
- return process.env.METAMASK_RECIPE_MOBILE_BRIDGE_SCRIPT ?? path.join(runnerDir, "adapters", "mobile", "bridge-runtime", "cdp-bridge.cjs");
8
- }
9
5
  const CONTROLLER_PREAMBLE = `
10
6
  var ctx = globalThis.Engine && globalThis.Engine.context;
11
7
  var c = ctx && ctx.RemoteFeatureFlagController;
@@ -19,7 +15,7 @@ function evalExpression(body) {
19
15
  return `(function(){ try {${CONTROLLER_PREAMBLE}${body} } catch (e) { return JSON.stringify({ ok: false, error: e && (e.message || String(e)) }); } })()`;
20
16
  }
21
17
  function runBridgeEval(target, expression, timeoutMs) {
22
- const script = bridgeScriptPath();
18
+ const script = mobileBridgeScript();
23
19
  if (!fs.existsSync(script)) return Promise.resolve(null);
24
20
  return new Promise((resolve) => {
25
21
  let settled = false;
@@ -2,7 +2,6 @@ import { EXIT, isRecord } from "@farmslot/recipe-cli/harness";
2
2
  import { spawnSync } from "node:child_process";
3
3
  import path from "node:path";
4
4
  import { IOS_SIMULATOR_RECORDING_TARGET_PREFIX } from "@farmslot/adapter-rn";
5
- import { runnerDir } from "../../paths.js";
6
5
  import { devicePlatformFromEnv } from "../performance/device-platform.js";
7
6
  import {
8
7
  resolveAndroidDeviceSerial,
@@ -10,6 +9,7 @@ import {
10
9
  } from "./recording-target.js";
11
10
  import { emitProofFailure } from "../proof-failure.js";
12
11
  import { mobileAppIdentityEnv } from "./app-identity.js";
12
+ import { mobileBridgeScript } from "./bridge-script.js";
13
13
  import { requireCurrentMobileReleaseArtifactState } from "./release-artifact-state.js";
14
14
  const EXPLICIT_DEVICE_KEYS = [
15
15
  "PLATFORM",
@@ -158,7 +158,7 @@ const mobileObservation = {
158
158
  }
159
159
  };
160
160
  function runMobileIssueCommand(projectRoot, command) {
161
- const bridge = process.env.METAMASK_RECIPE_MOBILE_BRIDGE_SCRIPT || path.join(runnerDir, "adapters", "mobile", "bridge-runtime", "cdp-bridge.cjs");
161
+ const bridge = mobileBridgeScript();
162
162
  const result = spawnSync(process.execPath, [bridge, command], {
163
163
  cwd: projectRoot,
164
164
  env: { ...process.env, APP_ROOT: projectRoot, CDP_TIMEOUT: "5000" },
@@ -10,6 +10,7 @@ import { trustedMutationContextExtras } from "../../funding-execution-context.js
10
10
  import { LIBRARY_ACTION_NAMESPACE } from "../../paths.js";
11
11
  import { liveTsxCandidates } from "../../live-tsx.js";
12
12
  import { mobileAppIdentityEnv } from "./app-identity.js";
13
+ import { mobileBridgeScriptOverride } from "./bridge-script.js";
13
14
  const execFileAsync = promisify(execFile);
14
15
  function sleep(ms) {
15
16
  return new Promise((resolve) => setTimeout(resolve, ms));
@@ -58,7 +59,7 @@ function mobileProbeOutput(status, input, projectRoot) {
58
59
  };
59
60
  }
60
61
  function mobileBridgePath(_projectRoot) {
61
- return process.env.METAMASK_RECIPE_MOBILE_BRIDGE_SCRIPT || "runner:adapters/mobile/bridge-runtime/cdp-bridge.cjs";
62
+ return mobileBridgeScriptOverride() ?? "runner:adapters/mobile/bridge-runtime/cdp-bridge.cjs";
62
63
  }
63
64
  function resolveMobileWaitTarget(payload) {
64
65
  const testId = optionalScalarText(
@@ -1,13 +1,9 @@
1
1
  import { spawn } from "node:child_process";
2
2
  import fs from "node:fs";
3
- import path from "node:path";
4
3
  import { fixtureSummary } from "../doctor.js";
5
4
  import { normalizeFeatureFlagRecord } from "../feature-flags.js";
6
- import { runnerDir } from "../paths.js";
5
+ import { mobileBridgeScript } from "../adapters/mobile/bridge-script.js";
7
6
  const PROBE_TIMEOUT_MS = 2e3;
8
- function bridgeScriptPath() {
9
- return process.env.METAMASK_RECIPE_MOBILE_BRIDGE_SCRIPT ?? path.join(runnerDir, "adapters", "mobile", "bridge-runtime", "cdp-bridge.cjs");
10
- }
11
7
  function truncateAddress(address) {
12
8
  if (!address || address.length <= 10) return address;
13
9
  return `${address.slice(0, 6)}\u2026${address.slice(-4)}`;
@@ -77,7 +73,7 @@ function matchBridgeEntry(device, entries, devices) {
77
73
  return null;
78
74
  }
79
75
  function probeMobileBridge(target) {
80
- const script = bridgeScriptPath();
76
+ const script = mobileBridgeScript();
81
77
  if (!fs.existsSync(script)) {
82
78
  return Promise.resolve({ ok: false, liveState: "no-bridge" });
83
79
  }
@@ -208,10 +208,11 @@ async function prepareCloseModal(page, partialAmount) {
208
208
  return confirmation;
209
209
  }
210
210
 
211
- async function proveGlobalCleanup(page, balanceBeforeBaseUnits, toleranceBaseUnits, timeoutMs = 20_000) {
211
+ async function proveGlobalCleanup(page, balanceBeforeBaseUnits, releaseBaseUnits, toleranceBaseUnits, timeoutMs = 20_000) {
212
212
  await page.navigateHash('#/perps-home');
213
213
  const state = await waitForStableGlobalPerpsHome(page, { timeoutMs, requireZero: true });
214
- const balanceDeltaBaseUnits = absoluteDelta(state.balanceBaseUnits, balanceBeforeBaseUnits);
214
+ const expectedBalanceAfterBaseUnits = `${BigInt(balanceBeforeBaseUnits) + BigInt(releaseBaseUnits ?? '0')}`;
215
+ const balanceDeltaBaseUnits = absoluteDelta(state.balanceBaseUnits, expectedBalanceAfterBaseUnits);
215
216
  if (BigInt(balanceDeltaBaseUnits) > BigInt(toleranceBaseUnits)) {
216
217
  throw new Error('Final visible Perps balance exceeds the authorized reconciliation tolerance.');
217
218
  }
@@ -219,6 +220,8 @@ async function proveGlobalCleanup(page, balanceBeforeBaseUnits, toleranceBaseUni
219
220
  positionCount: 0,
220
221
  orderCount: 0,
221
222
  balanceBeforeBaseUnits,
223
+ releaseBaseUnits: releaseBaseUnits ?? null,
224
+ expectedBalanceAfterBaseUnits,
222
225
  balanceAfterBaseUnits: state.balanceBaseUnits,
223
226
  balanceDeltaBaseUnits,
224
227
  toleranceBaseUnits,
@@ -327,9 +330,14 @@ export async function closeVisiblePosition(input) {
327
330
  if (!/^(?:0|[1-9][0-9]{0,29})$/u.test(balanceBeforeBaseUnits)) {
328
331
  throw new Error('Resumed close receipt has no valid pre-order balance.');
329
332
  }
333
+ const releaseBaseUnits = existingReceipt.record?.facts?.closeReleaseBaseUnits ?? null;
334
+ if (releaseBaseUnits != null && !/^-?(?:0|[1-9][0-9]{0,29})$/u.test(String(releaseBaseUnits))) {
335
+ throw new Error('Resumed close receipt has an invalid close release amount.');
336
+ }
330
337
  const cleanup = await proveGlobalCleanup(
331
338
  page,
332
339
  balanceBeforeBaseUnits,
340
+ releaseBaseUnits,
333
341
  balanceToleranceBaseUnits,
334
342
  );
335
343
  return {
@@ -413,6 +421,11 @@ export async function closeVisiblePosition(input) {
413
421
  if (partialAmount == null && (!confirmation.fullSizeSelected || !confirmation.marketOrder)) {
414
422
  throw new Error('Receipt-bound full close requires the visible 100% market-close form.');
415
423
  }
424
+ // Without a placement receipt the baseline is the in-position balance, which excludes the released margin.
425
+ const closeReleaseBaseUnits = placement ? null : parseUsdBaseUnits(confirmation.receive);
426
+ if (!placement && closeReleaseBaseUnits == null) {
427
+ throw new Error('Close without a placement receipt requires the visible close receive amount.');
428
+ }
416
429
  const closeSizing = partialAmount == null ? { size: sizeMatch[1], side: fundingSide === 'long' ? 'short' : 'long' } : null;
417
430
  let submissionObserver = null;
418
431
  let mutation;
@@ -427,6 +440,7 @@ export async function closeVisiblePosition(input) {
427
440
  cleanupOnly,
428
441
  fundingBalance,
429
442
  tradingContext,
443
+ ...(closeReleaseBaseUnits != null ? { closeReleaseBaseUnits } : {}),
430
444
  ...(acceptedFill ? { acceptedFill } : {}),
431
445
  ...(closeSizing ? { closeSizing } : {}),
432
446
  },
@@ -507,6 +521,7 @@ export async function closeVisiblePosition(input) {
507
521
  const cleanup = await proveGlobalCleanup(
508
522
  page,
509
523
  balanceBeforeBaseUnits,
524
+ closeReleaseBaseUnits,
510
525
  balanceToleranceBaseUnits,
511
526
  );
512
527
  return {
@@ -107,6 +107,8 @@ export async function runAdapter(callback) {
107
107
  }
108
108
  }
109
109
 
110
+ // Same rule as src/adapters/mobile/bridge-script.ts (unset or empty means the
111
+ // bundled bridge); the library cannot import src/.
110
112
  function bridgeScript(input) {
111
113
  if (process.env.METAMASK_RECIPE_MOBILE_BRIDGE_SCRIPT) {
112
114
  return process.env.METAMASK_RECIPE_MOBILE_BRIDGE_SCRIPT;
@@ -16,6 +16,41 @@ import { mobileToolRecovery, resolveMobileToolPath } from '@farmslot/adapter-rn'
16
16
  const execFileAsync = promisify(execFile);
17
17
  const VISIBLE_LIMIT = 200;
18
18
  const HIDDEN_LIMIT = 100;
19
+ // iOS accessibility exposes React Native Text as StaticText without its testID.
20
+ // Read each mounted Perps Text's testID, string and size (synchronous on Fabric)
21
+ // so the visible native element with the same label and size can carry that
22
+ // testID. Only perps- Texts leave the app: other Text, such as a revealed
23
+ // Secret Recovery Phrase word, never reaches the harness or a trace.
24
+ const TEXT_TEST_ID_LIMIT = 500;
25
+ const IOS_TEXT_TEST_IDS = `(() => {
26
+ const ui = globalThis.nativeFabricUIManager;
27
+ const hook = globalThis.__REACT_DEVTOOLS_GLOBAL_HOOK__;
28
+ if (!ui?.measureInWindow || !hook?.getFiberRoots) return { texts: [], total: 0 };
29
+ const texts = [];
30
+ const content = (fiber) => {
31
+ let value = '';
32
+ for (let child = fiber.child; child; child = child.sibling) {
33
+ value += typeof child.memoizedProps === 'string' || typeof child.memoizedProps === 'number'
34
+ ? String(child.memoizedProps) : content(child);
35
+ }
36
+ return value;
37
+ };
38
+ const visit = (fiber) => {
39
+ for (; fiber; fiber = fiber.sibling) {
40
+ const testId = fiber.memoizedProps?.testID;
41
+ if (fiber.type === 'RCTText' && typeof testId === 'string' && testId.startsWith('perps-') && fiber.stateNode?.node) {
42
+ let bounds;
43
+ ui.measureInWindow(fiber.stateNode.node, (x, y, width, height) => { bounds = { x, y, width, height }; });
44
+ if (bounds) texts.push({ testId, label: content(fiber), bounds });
45
+ }
46
+ visit(fiber.child);
47
+ }
48
+ };
49
+ const roots = new Set();
50
+ for (const id of hook.renderers.keys()) hook.getFiberRoots(id)?.forEach((root) => roots.add(root));
51
+ roots.forEach((root) => visit(root.current));
52
+ return { texts: texts.slice(0, ${TEXT_TEST_ID_LIMIT}), total: texts.length };
53
+ })()`;
19
54
 
20
55
  export async function observeNativeUi(payload, context) {
21
56
  await hideAccessibilityMaskingHud(payload, context);
@@ -39,7 +74,26 @@ export async function observeNativeUi(payload, context) {
39
74
  warnings.push({ ref: 'ui.screen', message: `Navigation route unavailable: ${error.message}` });
40
75
  }
41
76
  }
42
- const hierarchy = await readNativeHierarchy(payload, context);
77
+ let textTestIds = [];
78
+ if (
79
+ supported.includes('ui.visible') &&
80
+ resolvePlatform(record(payload?.node), env) === 'ios' &&
81
+ !isOpaqueRuntime(env) && (env.METRO_PORT || env.WATCHER_PORT)
82
+ ) {
83
+ try {
84
+ const read = await bridgeCommand(routeInput, ['eval', IOS_TEXT_TEST_IDS]);
85
+ textTestIds = Array.isArray(read?.texts) ? read.texts : [];
86
+ if (read?.total > textTestIds.length) {
87
+ warnings.push({
88
+ ref: 'ui.visible',
89
+ message: `Text test IDs truncated: read ${textTestIds.length} of ${read.total}.`,
90
+ });
91
+ }
92
+ } catch (error) {
93
+ warnings.push({ ref: 'ui.visible', message: `Text test IDs unavailable: ${error.message}` });
94
+ }
95
+ }
96
+ const hierarchy = await readNativeHierarchy(payload, context, textTestIds);
43
97
  if (routeBefore?.name) {
44
98
  const routeAfter = await bridgeCommand(routeInput, ['get-route']);
45
99
  if (routeAfter?.name !== routeBefore.name || routeAfter?.key !== routeBefore.key) {
@@ -85,7 +139,7 @@ export async function hideAccessibilityMaskingHud(payload, context) {
85
139
  }
86
140
  }
87
141
 
88
- async function readNativeHierarchy(payload, context) {
142
+ async function readNativeHierarchy(payload, context, textTestIds) {
89
143
  const node = record(payload?.node);
90
144
  const env = {
91
145
  ...process.env,
@@ -98,13 +152,13 @@ async function readNativeHierarchy(payload, context) {
98
152
  const appLabel = nativeAppLabel(env);
99
153
  const hierarchy = platform === 'android'
100
154
  ? await readAndroidHierarchy(node, env)
101
- : await readIosHierarchy(node, env);
155
+ : await readIosHierarchy(node, env, textTestIds);
102
156
  // No screen name and no labelled item: name the screen after the app.
103
157
  hierarchy.screen.name ??= appLabel;
104
158
  return hierarchy;
105
159
  }
106
160
 
107
- async function readIosHierarchy(node, env) {
161
+ async function readIosHierarchy(node, env, textTestIds) {
108
162
  const idbPath = resolveMobileToolPath('idb');
109
163
  if (!idbPath) {
110
164
  throw new Error(
@@ -123,7 +177,7 @@ async function readIosHierarchy(node, env) {
123
177
  },
124
178
  );
125
179
  const parsed = parseJsonOutput(stdout);
126
- const hierarchy = flattenIosHierarchy(parsed);
180
+ const hierarchy = flattenIosHierarchy(parsed, textTestIds);
127
181
  if (hierarchy.nodeCount === 0) {
128
182
  throw new Error('idb returned an empty accessibility hierarchy.');
129
183
  }
@@ -364,7 +418,12 @@ function parseJsonOutput(output) {
364
418
  }
365
419
  }
366
420
 
367
- function flattenIosHierarchy(value) {
421
+ function flattenIosHierarchy(value, textTestIds = []) {
422
+ const texts = (Array.isArray(textTestIds) ? textTestIds : []).map((entry) => ({
423
+ testId: text(entry?.testId),
424
+ label: text(entry?.label),
425
+ bounds: normalizeBounds(entry?.bounds),
426
+ })).filter((entry) => entry.testId && entry.label && entry.bounds);
368
427
  const visible = [];
369
428
  const hidden = [];
370
429
  let nodeCount = 0;
@@ -382,9 +441,10 @@ function flattenIosHierarchy(value) {
382
441
  text(item.role_description) ?? text(item.className);
383
442
  const label = text(item.label) ?? text(item.AXLabel) ?? text(item.name) ??
384
443
  text(item.title);
385
- const testId = text(item.identifier) ?? text(item.accessibilityIdentifier) ??
386
- text(item.AXUniqueId) ?? text(item.id);
387
444
  const bounds = normalizeBounds(item.frame ?? item.AXFrame ?? item.bounds);
445
+ const testId = text(item.identifier) ?? text(item.accessibilityIdentifier) ??
446
+ text(item.AXUniqueId) ?? text(item.id) ??
447
+ (label && hasVisibleBounds(bounds, screenBounds) ? textTestId(texts, label, bounds) : undefined);
388
448
  if (isApplicationRole(role)) {
389
449
  screenName ??= label;
390
450
  screenBounds ??= bounds;
@@ -466,6 +526,17 @@ function parseAndroidHierarchy(output) {
466
526
  return { visible, hidden, nodeCount, screenName };
467
527
  }
468
528
 
529
+ // Bind an exact label and size to one testID. Origins are not compared: Fabric
530
+ // frames omit native screen offsets such as a modal card. A same-text twin with
531
+ // another size (the collapsed header price) or another testID stays unbound.
532
+ function textTestId(texts, label, bounds) {
533
+ const testIds = new Set(texts
534
+ .filter((entry) => entry.label === label &&
535
+ ['width', 'height'].every((key) => Math.abs(entry.bounds[key] - bounds[key]) <= 1))
536
+ .map((entry) => entry.testId));
537
+ return testIds.size === 1 ? [...testIds][0] : undefined;
538
+ }
539
+
469
540
  function isApplicationRole(role) {
470
541
  return role?.toLowerCase().includes('application') ?? false;
471
542
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@deeeed/metamask-harness",
3
- "version": "0.86.0",
3
+ "version": "0.86.1",
4
4
  "type": "module",
5
5
  "bin": {
6
6
  "mm-harness": "bin/mm-harness"