@deeeed/metamask-harness 0.12.0 → 0.13.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/CHANGELOG.md CHANGED
@@ -1,9 +1,17 @@
1
1
  # Changelog
2
2
 
3
- ## Unreleased
3
+ ## 0.13.0 - 2026-07-08
4
+
5
+ ### Added
6
+ - Typed cdp-bridge failure codes (`NO_TARGET`, `CDP_TIMEOUT`, `WS_CLOSED`, `METRO_UNREACHABLE`): the bridge classifies a failure at its source and reports it three ways a caller can recover — an `ERROR[<CODE>]:` stderr marker, a code-specific exit status (10–13), and a `Next:` teaching line for the caller's actual situation. `bridge.mjs` attaches the code to the error it throws, and `app.hud` lifecycle-skip classification (`isMobileHudLifecycleSkip`) branches on the code first, keeping the substring needles only as a fallback for output from a bridge that predates the codes. The needle fallback is now case-insensitive, closing a gap where target-discovery's `Pinned Android device …` (capital A) missed a lowercase needle.
7
+ - Console-forwarder now expands object/array console arguments in `metro.log` from the Hermes-supplied inline `preview` (e.g. `{ symbol: "BTC", px: 42 }` instead of the literal `Object`), bounded one level deep and rendered synchronously so the stream never blocks; objects without a preview degrade to their description.
8
+ - `mm-harness call <action> --help` now renders the named action's own field schema (name, type, required, description, example — from the action manifest) above the generic call flags, instead of printing only the generic call help. Fuzzy short-name resolves like `call` (an ambiguous prefix shows every match); an unresolvable name falls back to the generic help plus a pointer to `mm-harness actions`. `run <recipe> --help` is unchanged (recipe-specific help is a separate, larger surface — recipes are not in the action manifest).
4
9
 
5
10
  ### Fixed
6
11
  - Runner construction no longer fails with `Manifest action app.lifecycle has no registered adapter` when a manifest declaring `app.lifecycle` is used with a non-mobile adapter (e.g. `call --adapter core --action-manifest library/manifests/mobile.action-manifest.json`): lifecycle adapter registration is manifest-driven instead of gated on the mobile adapter. Executing the action outside a mobile run still fails explicitly at target resolution.
12
+ - Mobile run teardown always clears any HUD step left painted on-device, so a failed run no longer strands a FAIL banner for the next run. Best-effort and bounded: a down/transitioning bridge simply has nothing to clear and never masks the run's real outcome.
13
+ - start-metro quick-launch now applies the dev Sentry DSN remap (`MM_SENTRY_DSN` defaults to `MM_SENTRY_DSN_DEV` when unset) at both launch paths. The quick-launch runs `expo start` directly and bypasses `scripts/build.sh`, which normally performs this remap, so without it Sentry never initialized in quick-launched dev clients.
14
+ - `launch ios|android` no longer claims `app + bridge ready` when the app is not actually up for the requested platform. On a dual-platform slot (one iOS simulator + one Android device sharing a Metro), a "ready" verdict could be reached from a shared successful bundle plus a cached OS process, and an answering bridge target belonging to the OTHER platform satisfied the claim — so `launch android` printed ✓ while nothing ran on the Android device. The quick-relaunch path now confirms a bridge target bound to the REQUESTED platform before claiming ready: the platform is passed explicitly to `wait-for-bridge` (`--platform ios|android`), so a slot-injected `IOS_SIMULATOR`/`ADB_SERIAL` for the other platform can no longer decide the match. The boundary-safe platform/device matcher is now a single shared module (`bridge-runtime/lib/match-bridge-target.cjs`) used by both `wait-for-bridge` and the confirm. If no matched target answers it launches the app for real, and if the platform-matched target still cannot be brought up it fails loudly. `wait-for-bridge` timeouts now name what was requested vs what answered.
7
15
 
8
16
  ## 0.12.0 - 2026-07-08
9
17
 
@@ -21,6 +21,12 @@ const path = require('node:path');
21
21
  const { loadPort } = require('./lib/config.cjs');
22
22
  const { discoverTarget } = require('./lib/target-discovery.cjs');
23
23
  const { createWSClient } = require('./lib/ws-client.cjs');
24
+ const {
25
+ EXIT_CODE_BY_ERROR_CODE,
26
+ TEACHING_BY_ERROR_CODE,
27
+ classifyBridgeErrorMessage,
28
+ formatErrorMarker,
29
+ } = require('./lib/bridge-errors.cjs');
24
30
  const { cdpEval, cdpEvalAsync } = require('./lib/cdp-eval.cjs');
25
31
  const { buildArmSnippet, buildCollectSnippet } = require('./lib/issue-capture.cjs');
26
32
 
@@ -746,6 +752,16 @@ Environment:
746
752
  }
747
753
 
748
754
  main().catch((err) => {
755
+ // Typed failure: a code stamped at the throw site wins; otherwise classify the
756
+ // message here (still at the bridge, not by a needle far away in adapters.ts).
757
+ // The marker + teaching go to stderr and the exit code carries the class, so a
758
+ // caller recovers the code from either channel.
759
+ const code = err && err.code ? err.code : classifyBridgeErrorMessage(err && err.message);
760
+ if (code) {
761
+ console.error(formatErrorMarker(code, err.message));
762
+ if (TEACHING_BY_ERROR_CODE[code]) console.error(TEACHING_BY_ERROR_CODE[code]);
763
+ process.exit(EXIT_CODE_BY_ERROR_CODE[code] || 1);
764
+ }
749
765
  console.error(`ERROR: ${err.message}`);
750
766
  process.exit(1);
751
767
  });
@@ -28,6 +28,7 @@ const fs = require('node:fs');
28
28
  const http = require('node:http');
29
29
  const path = require('node:path');
30
30
  const { rankRuntimeCandidates } = require('./lib/target-discovery.cjs');
31
+ const { formatArgs: formatConsoleArgs } = require('./lib/console-format.cjs');
31
32
 
32
33
  // Built-in WebSocket (Node 22+), same zero-dependency transport choice as
33
34
  // lib/ws-client.cjs — the published package must not depend on `ws`.
@@ -201,10 +202,7 @@ function levelLabel(type) {
201
202
  }
202
203
 
203
204
  function formatArgs(args) {
204
- const text = (args || [])
205
- .map((a) => (a.value !== undefined ? String(a.value) : (a.description ?? a.type ?? '')))
206
- .join(' ');
207
- return text.length > MAX_LINE_CHARS ? `${text.slice(0, MAX_LINE_CHARS)}…` : text;
205
+ return formatConsoleArgs(args, MAX_LINE_CHARS);
208
206
  }
209
207
 
210
208
  function deviceNameFromTitle(title) {
@@ -0,0 +1,106 @@
1
+ 'use strict';
2
+
3
+ // Typed bridge failure codes — the single source of truth for classifying a
4
+ // cdp-bridge failure. Substring needles on a critical path are brittle; the
5
+ // bridge classifies at the source and stamps a code, so callers (bridge.mjs,
6
+ // adapters.ts) branch on the code and only fall back to needles for output
7
+ // produced by an older bridge that predates the codes.
8
+ //
9
+ // NO_TARGET no debug target answered (app backgrounded / not attached
10
+ // / a device pin matched nothing).
11
+ // CDP_TIMEOUT the CDP connection or a single CDP message timed out.
12
+ // WS_CLOSED the Hermes debug socket closed mid-command (app reload).
13
+ // METRO_UNREACHABLE Metro's inspector HTTP endpoint could not be reached.
14
+ const BRIDGE_ERROR_CODES = {
15
+ NO_TARGET: 'NO_TARGET',
16
+ CDP_TIMEOUT: 'CDP_TIMEOUT',
17
+ WS_CLOSED: 'WS_CLOSED',
18
+ METRO_UNREACHABLE: 'METRO_UNREACHABLE',
19
+ };
20
+
21
+ // Process exit code the bridge returns per failure code so a caller that only
22
+ // sees the child's exit status (no stderr) can still recover the code. Kept
23
+ // clear of exit 1 (unknown/uncoded) and 2 (usage).
24
+ const EXIT_CODE_BY_ERROR_CODE = {
25
+ NO_TARGET: 10,
26
+ CDP_TIMEOUT: 11,
27
+ WS_CLOSED: 12,
28
+ METRO_UNREACHABLE: 13,
29
+ };
30
+
31
+ const ERROR_CODE_BY_EXIT_CODE = Object.fromEntries(
32
+ Object.entries(EXIT_CODE_BY_ERROR_CODE).map(([code, exit]) => [exit, code]),
33
+ );
34
+
35
+ // Every coded failure teaches the escape for the caller's ACTUAL situation — a
36
+ // down/transitioning target is recoverable, so the Next: line names the action
37
+ // that brings it back rather than leaving the operator to guess.
38
+ const TEACHING_BY_ERROR_CODE = {
39
+ NO_TARGET:
40
+ 'Next: bring the app to the foreground with `mm-harness launch <platform>` so a debug target is available.',
41
+ CDP_TIMEOUT:
42
+ 'Next: the app is reloading or busy — retry, or `mm-harness launch <platform>` to foreground it.',
43
+ WS_CLOSED:
44
+ 'Next: the Hermes debug page closed (app reload/backgrounded) — retry once the app settles.',
45
+ METRO_UNREACHABLE:
46
+ 'Next: start Metro with `mm-harness start-metro` (if it is already up, check the slot WATCHER_PORT).',
47
+ };
48
+
49
+ // Needle → code fallback for uncoded output (older bridge). Case-insensitive,
50
+ // first match wins, so order the more specific Metro/no-target needles ahead of
51
+ // the generic 'timed out'.
52
+ const NEEDLE_CODES = [
53
+ ['cannot reach metro', BRIDGE_ERROR_CODES.METRO_UNREACHABLE],
54
+ ['is metro running', BRIDGE_ERROR_CODES.METRO_UNREACHABLE],
55
+ ['timeout fetching', BRIDGE_ERROR_CODES.METRO_UNREACHABLE],
56
+ ['no responding bridge target', BRIDGE_ERROR_CODES.NO_TARGET],
57
+ ['no debug targets found', BRIDGE_ERROR_CODES.NO_TARGET],
58
+ ['no suitable debug target', BRIDGE_ERROR_CODES.NO_TARGET],
59
+ ['did not match any metro target', BRIDGE_ERROR_CODES.NO_TARGET],
60
+ ['pinned android device', BRIDGE_ERROR_CODES.NO_TARGET],
61
+ ['no react native bridge target', BRIDGE_ERROR_CODES.NO_TARGET],
62
+ ['websocket closed', BRIDGE_ERROR_CODES.WS_CLOSED],
63
+ ['websocket error', BRIDGE_ERROR_CODES.WS_CLOSED],
64
+ ['cdp connection timeout', BRIDGE_ERROR_CODES.CDP_TIMEOUT],
65
+ ['cdp message timeout', BRIDGE_ERROR_CODES.CDP_TIMEOUT],
66
+ ['evaluation timed out', BRIDGE_ERROR_CODES.CDP_TIMEOUT],
67
+ ['timed out', BRIDGE_ERROR_CODES.CDP_TIMEOUT],
68
+ ];
69
+
70
+ function classifyBridgeErrorMessage(message) {
71
+ const text = String(message == null ? '' : message).toLowerCase();
72
+ for (const [needle, code] of NEEDLE_CODES) {
73
+ if (text.includes(needle)) return code;
74
+ }
75
+ return null;
76
+ }
77
+
78
+ // Attach a code to an error at its throw site (source classification).
79
+ function coded(error, code) {
80
+ if (error && typeof error === 'object') error.code = code;
81
+ return error;
82
+ }
83
+
84
+ // The bridge prints this marker on stderr so a caller recovers the code without
85
+ // relying on the exit status alone.
86
+ const MARKER = /^ERROR\[([A-Z_]+)\]:/mu;
87
+
88
+ function formatErrorMarker(code, message) {
89
+ return `ERROR[${code}]: ${message}`;
90
+ }
91
+
92
+ function parseErrorMarker(text) {
93
+ const match = MARKER.exec(String(text == null ? '' : text));
94
+ return match ? match[1] : null;
95
+ }
96
+
97
+ module.exports = {
98
+ BRIDGE_ERROR_CODES,
99
+ EXIT_CODE_BY_ERROR_CODE,
100
+ ERROR_CODE_BY_EXIT_CODE,
101
+ TEACHING_BY_ERROR_CODE,
102
+ classifyBridgeErrorMessage,
103
+ coded,
104
+ formatErrorMarker,
105
+ parseErrorMarker,
106
+ };
@@ -0,0 +1,53 @@
1
+ 'use strict';
2
+
3
+ // Render CDP Runtime.consoleAPICalled args (RemoteObjects) into a log line.
4
+ // Primitives carry `value`; objects/arrays do not — a bare console.log(obj)
5
+ // arrived as the literal "Object". Hermes ships an inline `preview` (the same
6
+ // one React Native DevTools renders in its console), so objects expand from it
7
+ // synchronously — no extra CDP round-trip, so the stream never blocks. Depth is
8
+ // bounded because previews nest one level via `valuePreview`; beyond that the
9
+ // child preview's own description is used.
10
+
11
+ const MAX_PREVIEW_DEPTH = 2;
12
+
13
+ function formatPropertyValue(prop, depth) {
14
+ if (prop.valuePreview && depth < MAX_PREVIEW_DEPTH) {
15
+ return formatPreview(prop.valuePreview, depth + 1);
16
+ }
17
+ if (prop.type === 'string' && prop.value !== undefined) return JSON.stringify(prop.value);
18
+ if (prop.value !== undefined) return String(prop.value);
19
+ return prop.subtype || prop.type || '';
20
+ }
21
+
22
+ function formatPreview(preview, depth) {
23
+ const properties = Array.isArray(preview.properties) ? preview.properties : [];
24
+ const overflow = preview.overflow ? ', …' : '';
25
+ if (preview.subtype === 'array') {
26
+ return `[${properties.map((p) => formatPropertyValue(p, depth)).join(', ')}${overflow}]`;
27
+ }
28
+ const body = properties.map((p) => `${p.name}: ${formatPropertyValue(p, depth)}`).join(', ');
29
+ // Name a non-plain constructor (Error, Map, custom class) so the class is not lost.
30
+ const ctor = preview.description && preview.description !== 'Object' ? `${preview.description} ` : '';
31
+ return properties.length > 0 || overflow ? `${ctor}{ ${body}${overflow} }` : `${ctor}{}`;
32
+ }
33
+
34
+ function formatRemoteObject(a) {
35
+ if (!a || typeof a !== 'object') return String(a == null ? '' : a);
36
+ // Primitives (number/boolean/string) carry a directly-usable value.
37
+ if (a.type !== 'object' && a.type !== 'function' && a.value !== undefined) return String(a.value);
38
+ if (a.type === 'undefined') return 'undefined';
39
+ if (a.subtype === 'null') return 'null';
40
+ if (a.preview) return formatPreview(a.preview, 1);
41
+ // Functions, errors, and objects with no preview: the description is the best
42
+ // available text (className / Error stack / etc.); fall back to bare value/type.
43
+ if (a.description !== undefined) return a.description;
44
+ if (a.value !== undefined) return String(a.value);
45
+ return a.type || '';
46
+ }
47
+
48
+ function formatArgs(args, maxLineChars) {
49
+ const text = (args || []).map(formatRemoteObject).join(' ');
50
+ return maxLineChars && text.length > maxLineChars ? `${text.slice(0, maxLineChars)}…` : text;
51
+ }
52
+
53
+ module.exports = { formatArgs, formatRemoteObject, formatPreview };
@@ -0,0 +1,82 @@
1
+ 'use strict';
2
+
3
+ // Boundary-safe matcher for CDP bridge targets, shared by wait-for-bridge and the
4
+ // launch ready-path confirm. A launch (or its confirm) may only accept a target
5
+ // belonging to the REQUESTED platform — and the requested device when one is pinned.
6
+ //
7
+ // The requested platform is authoritative and is passed explicitly
8
+ // (WAIT_FOR_BRIDGE_PLATFORM), so it wins over env inference. On a dual-platform slot
9
+ // the other platform's device identity is ambiently injected (a slot-context
10
+ // IOS_SIMULATOR while launching android, or vice versa); without an explicit platform
11
+ // the matcher would fall back to that ambient identity and confirm against the wrong
12
+ // platform's target. Binding the platform up front closes that corner.
13
+
14
+ function matchesBridgeTarget(target, env) {
15
+ const e = env || process.env;
16
+ if (!target || typeof target !== 'object') return false;
17
+
18
+ const requirePlatform = e.WAIT_FOR_BRIDGE_PLATFORM || '';
19
+ // Requested platform is authoritative: never accept the other platform's target,
20
+ // whatever device env is ambiently present.
21
+ if (requirePlatform && target.platform !== requirePlatform) return false;
22
+
23
+ const androidName = e.ANDROID_TARGET_DEVICE_NAME || e.ANDROID_DEVICE || '';
24
+ const adbSerial = e.ADB_SERIAL || e.ANDROID_SERIAL || '';
25
+ const iosSimulator = e.IOS_SIMULATOR || '';
26
+
27
+ // Android device pin applies only when android is requested (or nothing constrains
28
+ // the platform); it must never redirect a platform-bound iOS confirm — a slot-context
29
+ // ADB_SERIAL while launching iOS must not reject every iOS target. Symmetric with the
30
+ // iOS-simulator branch below.
31
+ if ((adbSerial || androidName) && (!requirePlatform || requirePlatform === 'android')) {
32
+ // Require android + boundary-safe device-name match.
33
+ if (target.platform !== 'android') return false;
34
+ if (!androidName) return true;
35
+ const deviceName = String(target.deviceName || '');
36
+ return deviceName === androidName || deviceName.startsWith(`${androidName} -`);
37
+ }
38
+
39
+ // iOS simulator pin applies only when iOS is requested (or nothing constrains the
40
+ // platform); it must never redirect a platform-bound android confirm.
41
+ if (iosSimulator && (!requirePlatform || requirePlatform === 'ios')) {
42
+ return target.platform !== 'android' && target.deviceName === iosSimulator;
43
+ }
44
+
45
+ // No device pin: the platform gate above is the only constraint.
46
+ return true;
47
+ }
48
+
49
+ // True when at least one answering target matches the request AND carries a route
50
+ // (an in-app agentic bridge is live), not merely a registered debug target.
51
+ function hasMatchingRoute(value, env) {
52
+ const targets = Array.isArray(value) ? value : [value];
53
+ return targets.some((t) => matchesBridgeTarget(t, env) && t && t.route);
54
+ }
55
+
56
+ // Human summary of the targets that answered (platform / deviceName), for teaching on
57
+ // a timeout — e.g. an iOS target answering an android launch.
58
+ function describeTargets(value) {
59
+ const targets = (Array.isArray(value) ? value : [value]).filter((t) => t && typeof t === 'object');
60
+ if (!targets.length) return 'none';
61
+ return targets
62
+ .map((t) => `${t.platform || '?'}${t.deviceName ? `/'${t.deviceName}'` : ''}${t.route ? '' : ' (no route)'}`)
63
+ .join(', ');
64
+ }
65
+
66
+ // Human summary of what the run is pinned to, for the same teaching line.
67
+ function describeRequested(env) {
68
+ const e = env || process.env;
69
+ const platform = e.WAIT_FOR_BRIDGE_PLATFORM || '';
70
+ const androidName = e.ANDROID_TARGET_DEVICE_NAME || e.ANDROID_DEVICE || '';
71
+ const adbSerial = e.ADB_SERIAL || e.ANDROID_SERIAL || '';
72
+ const iosSimulator = e.IOS_SIMULATOR || '';
73
+ // Explicit requested platform wins over device-env inference: an iOS-bound confirm
74
+ // with an ambient ADB_SERIAL is still iOS.
75
+ if (platform === 'android') return `android${androidName ? ` / ${androidName}` : ''}`;
76
+ if (platform === 'ios') return `ios${iosSimulator ? ` / ${iosSimulator}` : ''}`;
77
+ if (adbSerial || androidName) return `android${androidName ? ` / ${androidName}` : ''}`;
78
+ if (iosSimulator) return `ios${iosSimulator ? ` / ${iosSimulator}` : ''}`;
79
+ return 'any platform';
80
+ }
81
+
82
+ module.exports = { matchesBridgeTarget, hasMatchingRoute, describeTargets, describeRequested };
@@ -3,6 +3,7 @@
3
3
  const http = require('node:http');
4
4
  const { loadSimulatorName, loadAndroidDevice, loadAndroidTargetDeviceName } = require('./config.cjs');
5
5
  const { createWSClient } = require('./ws-client.cjs');
6
+ const { BRIDGE_ERROR_CODES, coded } = require('./bridge-errors.cjs');
6
7
 
7
8
  const FETCH_TIMEOUT_MS = Number.parseInt(process.env.CDP_TIMEOUT || '30000', 10);
8
9
  const FETCH_RETRIES = Number.parseInt(process.env.CDP_DISCOVERY_RETRIES || '3', 10);
@@ -117,13 +118,14 @@ async function discoverTarget(port) {
117
118
  try {
118
119
  targets = await fetchJSON(listUrl);
119
120
  } catch (e) {
120
- throw new Error(
121
- `Cannot reach Metro at ${listUrl}. Is Metro running?\n ${e.message}`,
121
+ throw coded(
122
+ new Error(`Cannot reach Metro at ${listUrl}. Is Metro running?\n ${e.message}`),
123
+ BRIDGE_ERROR_CODES.METRO_UNREACHABLE,
122
124
  );
123
125
  }
124
126
 
125
127
  if (!Array.isArray(targets) || targets.length === 0) {
126
- throw new Error(`No debug targets found at ${listUrl}`);
128
+ throw coded(new Error(`No debug targets found at ${listUrl}`), BRIDGE_ERROR_CODES.NO_TARGET);
127
129
  }
128
130
 
129
131
  // Filter to React Native / Hermes targets with a WebSocket URL
@@ -181,25 +183,25 @@ async function discoverTarget(port) {
181
183
  const ambiguousList = androidFiltered
182
184
  .map((t) => ` deviceName=${JSON.stringify(t.deviceName || '')} ws=${t.webSocketDebuggerUrl || ''}`)
183
185
  .join('\n');
184
- throw new Error(
186
+ throw coded(new Error(
185
187
  `Pinned Android device is ambiguous: model '${androidTargetName}' matches ${androidFiltered.length} Metro targets.\n` +
186
188
  ` Requested --device (ADB_SERIAL): ${adbSerial || '(not set)'}\n` +
187
189
  ` Matching Metro targets:\n${ambiguousList}\n` +
188
190
  ` Set ANDROID_DEVICE to the exact Metro deviceName to disambiguate.`,
189
- );
191
+ ), BRIDGE_ERROR_CODES.NO_TARGET);
190
192
  }
191
193
  if (androidFiltered.length === 0) {
192
194
  // Pinned device could not be matched — never silently pick another target.
193
195
  const candidateList = targets
194
196
  .map((t) => ` deviceName=${JSON.stringify(t.deviceName || '')} ws=${t.webSocketDebuggerUrl || ''}`)
195
197
  .join('\n');
196
- throw new Error(
198
+ throw coded(new Error(
197
199
  `Pinned Android device did not match any Metro target.\n` +
198
200
  ` Requested --device (ADB_SERIAL): ${adbSerial || '(not set)'}\n` +
199
201
  ` Resolved model (ANDROID_TARGET_DEVICE_NAME): ${androidTargetName}\n` +
200
202
  ` ANDROID_DEVICE: ${androidDevice || '(not set)'}\n` +
201
203
  ` Metro /json/list candidates:\n${candidateList}`,
202
- );
204
+ ), BRIDGE_ERROR_CODES.NO_TARGET);
203
205
  }
204
206
  candidates = androidFiltered;
205
207
  } else if (androidDevice) {
@@ -211,11 +213,11 @@ async function discoverTarget(port) {
211
213
  const candidateList = targets
212
214
  .map((t) => ` deviceName=${JSON.stringify(t.deviceName || '')} ws=${t.webSocketDebuggerUrl || ''}`)
213
215
  .join('\n');
214
- throw new Error(
216
+ throw coded(new Error(
215
217
  `Pinned Android device (ANDROID_DEVICE='${androidDevice}') did not match any Metro target.\n` +
216
218
  ` ADB_SERIAL: ${adbSerial || '(not set)'}\n` +
217
219
  ` Metro /json/list candidates:\n${candidateList}`,
218
- );
220
+ ), BRIDGE_ERROR_CODES.NO_TARGET);
219
221
  }
220
222
  candidates = androidFiltered;
221
223
  }
@@ -226,8 +228,9 @@ async function discoverTarget(port) {
226
228
  }
227
229
 
228
230
  if (candidates.length === 0) {
229
- throw new Error(
230
- `No suitable debug target found. Targets:\n${JSON.stringify(targets, null, 2)}`,
231
+ throw coded(
232
+ new Error(`No suitable debug target found. Targets:\n${JSON.stringify(targets, null, 2)}`),
233
+ BRIDGE_ERROR_CODES.NO_TARGET,
231
234
  );
232
235
  }
233
236
 
@@ -263,8 +266,9 @@ async function discoverAllTargets(port) {
263
266
  try {
264
267
  targets = await fetchJSON(listUrl);
265
268
  } catch (e) {
266
- throw new Error(
267
- `Cannot reach Metro at ${listUrl}. Is Metro running?\n ${e.message}`,
269
+ throw coded(
270
+ new Error(`Cannot reach Metro at ${listUrl}. Is Metro running?\n ${e.message}`),
271
+ BRIDGE_ERROR_CODES.METRO_UNREACHABLE,
268
272
  );
269
273
  }
270
274
 
@@ -2,6 +2,8 @@
2
2
 
3
3
  /* global globalThis */
4
4
 
5
+ const { BRIDGE_ERROR_CODES, coded } = require('./bridge-errors.cjs');
6
+
5
7
  /**
6
8
  * Minimal CDP client using the built-in ws-like interface over raw WebSocket.
7
9
  * Node 22+ has a built-in WebSocket; for older versions we use the ws package
@@ -32,7 +34,7 @@ function createWSClient(wsUrl, timeout) {
32
34
 
33
35
  const timer = setTimeout(() => {
34
36
  ws.close();
35
- reject(new Error(`CDP connection timeout after ${timeout}ms`));
37
+ reject(coded(new Error(`CDP connection timeout after ${timeout}ms`), BRIDGE_ERROR_CODES.CDP_TIMEOUT));
36
38
  }, timeout);
37
39
 
38
40
  ws.onopen = () => {
@@ -45,8 +47,11 @@ function createWSClient(wsUrl, timeout) {
45
47
  const timer = setTimeout(() => {
46
48
  pending.delete(id);
47
49
  rej(
48
- new Error(
49
- `CDP message timeout after ${msgTimeout}ms for ${method}`,
50
+ coded(
51
+ new Error(
52
+ `CDP message timeout after ${msgTimeout}ms for ${method}`,
53
+ ),
54
+ BRIDGE_ERROR_CODES.CDP_TIMEOUT,
50
55
  ),
51
56
  );
52
57
  }, msgTimeout);
@@ -92,13 +97,13 @@ function createWSClient(wsUrl, timeout) {
92
97
 
93
98
  ws.onerror = (err) => {
94
99
  clearTimeout(timer);
95
- reject(new Error(`WebSocket error: ${err.message || err}`));
100
+ reject(coded(new Error(`WebSocket error: ${err.message || err}`), BRIDGE_ERROR_CODES.WS_CLOSED));
96
101
  };
97
102
 
98
103
  ws.onclose = () => {
99
104
  clearTimeout(timer);
100
105
  for (const [, { reject: rej }] of pending) {
101
- rej(new Error('WebSocket closed'));
106
+ rej(coded(new Error('WebSocket closed'), BRIDGE_ERROR_CODES.WS_CLOSED));
102
107
  }
103
108
  pending.clear();
104
109
  };
@@ -120,6 +120,9 @@ set -a
120
120
  [ ! -f .env.local ] || . ./.env.local
121
121
  set +a
122
122
  [ -n "\${METAMASK_BUILD_TYPE:-}" ] || export METAMASK_BUILD_TYPE=main
123
+ # build.sh remaps MM_SENTRY_DSN from MM_SENTRY_DSN_DEV for dev builds; quick-launch
124
+ # bypasses it, so without this Sentry never initializes in the dev client.
125
+ [ -n "\${MM_SENTRY_DSN:-}" ] || export MM_SENTRY_DSN="\${MM_SENTRY_DSN_DEV:-}"
123
126
  export EXPO_NO_TYPESCRIPT_SETUP=1
124
127
  export WATCHER_PORT="$(printf '%q' "$PORT")" METRO_PORT="$(printf '%q' "$PORT")"
125
128
  export METRO_MAX_WORKERS="$(printf '%q' "$METRO_WORKERS")"
@@ -203,6 +206,10 @@ if ! start_metro_tmux; then
203
206
  # rejects. Default to the main dev client (what runway installs / launch targets)
204
207
  # so already-installed slots work without re-syncing fixtures.
205
208
  [ -n "${METAMASK_BUILD_TYPE:-}" ] || export METAMASK_BUILD_TYPE=main
209
+ # build.sh remaps MM_SENTRY_DSN from MM_SENTRY_DSN_DEV for dev builds (start:ios
210
+ # = build.sh ios main dev); quick-launch bypasses build.sh, so without this the
211
+ # dev client's MM_SENTRY_DSN stays empty and Sentry never initializes.
212
+ [ -n "${MM_SENTRY_DSN:-}" ] || export MM_SENTRY_DSN="${MM_SENTRY_DSN_DEV:-}"
206
213
  export EXPO_NO_TYPESCRIPT_SETUP=1
207
214
  export WATCHER_PORT="${PORT}" METRO_PORT="${PORT}"
208
215
  export METRO_MAX_WORKERS="${METRO_WORKERS}"
@@ -9,6 +9,8 @@
9
9
  # --target <metamask-mobile dir> (default $PWD)
10
10
  # --port <number> (default WATCHER_PORT env, else METRO_PORT, else 8081)
11
11
  # --max-polls <n> (default MOBILE_BRIDGE_READY_POLLS env, else 90; 2s each = 180s)
12
+ # --platform ios|android bind the required platform: the answering target
13
+ # must belong to it, regardless of ambient device env
12
14
  # Outputs:
13
15
  # Progress on stderr; exit 0 bridge ready; 1 timeout.
14
16
  #
@@ -18,14 +20,16 @@ set -uo pipefail
18
20
  TARGET="$PWD"
19
21
  PORT="${WATCHER_PORT:-${METRO_PORT:-8081}}"
20
22
  MAX_POLLS="${MOBILE_BRIDGE_READY_POLLS:-90}"
23
+ REQUIRE_PLATFORM=""
21
24
 
22
25
  while [ "$#" -gt 0 ]; do
23
26
  case "$1" in
24
27
  --target) TARGET="$2"; shift 2 ;;
25
28
  --port) PORT="$2"; shift 2 ;;
26
29
  --max-polls) MAX_POLLS="$2"; shift 2 ;;
30
+ --platform) REQUIRE_PLATFORM="$2"; shift 2 ;;
27
31
  -h|--help)
28
- printf 'Usage: wait-for-bridge.sh [--target <dir>] [--port <n>] [--max-polls <n>]\n'
32
+ printf 'Usage: wait-for-bridge.sh [--target <dir>] [--port <n>] [--max-polls <n>] [--platform ios|android]\n'
29
33
  exit 0
30
34
  ;;
31
35
  *) printf 'wait-for-bridge: unknown arg: %s\n' "$1" >&2; exit 2 ;;
@@ -34,6 +38,10 @@ done
34
38
 
35
39
  TARGET="$(cd "$TARGET" && pwd)"
36
40
 
41
+ # The matcher reads the required platform from the environment so the value survives
42
+ # into the node subprocess that runs the shared matcher against the status output.
43
+ export WAIT_FOR_BRIDGE_PLATFORM="$REQUIRE_PLATFORM"
44
+
37
45
  SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
38
46
  # shellcheck disable=SC1091
39
47
  . "$SCRIPT_DIR/../shared/harness-path.sh"
@@ -45,6 +53,7 @@ LOG_DIR="$TARGET/$(recipe_runtime_dir)" || exit 1
45
53
  mkdir -p "$LOG_DIR"
46
54
 
47
55
  BRIDGE_CJS="$(dirname "$0")/bridge-runtime/cdp-bridge.cjs"
56
+ MATCH_LIB="$(dirname "$0")/bridge-runtime/lib/match-bridge-target.cjs"
48
57
  STATUS_LOG="$LOG_DIR/bridge-status.log"
49
58
  METRO_LOG="$LOG_DIR/metro.log"
50
59
 
@@ -86,30 +95,37 @@ bridge_wait_reason() {
86
95
  bundle_progress "$METRO_LOG"
87
96
  }
88
97
 
98
+ # Matcher is the shared boundary-safe module (match-bridge-target.cjs), so the
99
+ # launch ready-path confirm and this poll enforce identical platform/device rules.
89
100
  bridge_has_route() {
90
101
  (cd "$TARGET" && APP_ROOT="$TARGET" node "$BRIDGE_CJS" status > "$STATUS_LOG" 2>&1) \
91
- && node - "$STATUS_LOG" <<'NODE'
92
- const fs = require('fs');
102
+ && node -e '
103
+ const { hasMatchingRoute } = require(process.argv[1]);
104
+ const fs = require("fs");
93
105
  try {
94
- const value = JSON.parse(fs.readFileSync(process.argv[2], 'utf8'));
95
- const targets = Array.isArray(value) ? value : [value];
96
- const androidName = process.env.ANDROID_TARGET_DEVICE_NAME || process.env.ANDROID_DEVICE || '';
97
- const adbSerial = process.env.ADB_SERIAL || process.env.ANDROID_SERIAL || '';
98
- const iosSimulator = process.env.IOS_SIMULATOR || '';
99
- const matchesTarget = (target) => {
100
- if (!target || typeof target !== 'object') return false;
101
- if (adbSerial || androidName) {
102
- if (target.platform !== 'android') return false;
103
- if (!androidName) return true;
104
- const deviceName = String(target.deviceName || '');
105
- return deviceName === androidName || deviceName.startsWith(`${androidName} -`);
106
- }
107
- if (iosSimulator) return target.deviceName === iosSimulator;
108
- return true;
109
- };
110
- if (!targets.some((t) => matchesTarget(t) && t.route)) process.exit(1);
106
+ const value = JSON.parse(fs.readFileSync(process.argv[2], "utf8"));
107
+ process.exit(hasMatchingRoute(value) ? 0 : 1);
111
108
  } catch { process.exit(1); }
112
- NODE
109
+ ' "$MATCH_LIB" "$STATUS_LOG"
110
+ }
111
+
112
+ # The device/platform this run is pinned to — the bridge target must belong to it.
113
+ requested_target() {
114
+ node -e 'process.stdout.write(require(process.argv[1]).describeRequested())' "$MATCH_LIB" 2>/dev/null \
115
+ || printf 'any platform'
116
+ }
117
+
118
+ # Summarise the targets that DID answer (platform / deviceName), so a timeout teaches
119
+ # what responded vs what was requested — e.g. an iOS target answering an android launch.
120
+ answered_targets() {
121
+ [ -s "$STATUS_LOG" ] || { printf 'none'; return; }
122
+ node -e '
123
+ const { describeTargets } = require(process.argv[1]);
124
+ const fs = require("fs");
125
+ try {
126
+ process.stdout.write(describeTargets(JSON.parse(fs.readFileSync(process.argv[2], "utf8"))));
127
+ } catch { process.stdout.write("none"); }
128
+ ' "$MATCH_LIB" "$STATUS_LOG" 2>/dev/null || printf 'none'
113
129
  }
114
130
 
115
131
  for ATTEMPT in $(seq 1 "$MAX_POLLS"); do
@@ -124,7 +140,9 @@ for ATTEMPT in $(seq 1 "$MAX_POLLS"); do
124
140
  done
125
141
 
126
142
  cat "$STATUS_LOG" >&2 || true
127
- printf 'wait-for-bridge: bridge did not become ready on port %s (%s polls × 2s = %ss)\n' \
128
- "$PORT" "$MAX_POLLS" "$((MAX_POLLS * 2))" >&2
143
+ printf 'wait-for-bridge: no bridge target matched the requested %s on port %s (%s polls × 2s = %ss)\n' \
144
+ "$(requested_target)" "$PORT" "$MAX_POLLS" "$((MAX_POLLS * 2))" >&2
145
+ printf ' requested: %s\n' "$(requested_target)" >&2
146
+ printf ' answered: %s\n' "$(answered_targets)" >&2
129
147
  printf ' Next: check %s — or run mm-harness app-status\n' "$STATUS_LOG" >&2
130
148
  exit 1
@@ -3,8 +3,10 @@ import { recordDepsBaseline } from "@farmslot/recipe-harness/runtime/deps-readin
3
3
  import { EXIT, spawnScriptStreaming } from "../../commands/shared.js";
4
4
  import { runnerDir } from "../../paths.js";
5
5
  import {
6
- decideMobileReadiness
6
+ decideMobileReadiness,
7
+ launchActions
7
8
  } from "./runtime-decision.js";
9
+ const READY_BRIDGE_CONFIRM_POLLS = 3;
8
10
  const POD_PROBE_ENV = {
9
11
  FORCE_COLOR: "0",
10
12
  NO_COLOR: "1",
@@ -42,6 +44,24 @@ async function prepareMobile(target, opts = {}) {
42
44
  return { status: EXIT.runtime, output: msg };
43
45
  }
44
46
  if (report.decision === "ready") {
47
+ const confirm = await dispatchAction(
48
+ { id: "wait-for-bridge", cwd: target, argv: ["--max-polls", String(READY_BRIDGE_CONFIRM_POLLS)] },
49
+ target,
50
+ platform,
51
+ json,
52
+ preflightMode
53
+ );
54
+ if (confirm.status === 0) return { status: 0, output: "" };
55
+ if (!json) {
56
+ process.stderr.write(
57
+ `launch: runtime looked ready but no ${platform} bridge target answered; launching the app.
58
+ `
59
+ );
60
+ }
61
+ for (const action of launchActions(path.resolve(target))) {
62
+ const result = await dispatchAction(action, target, platform, json, preflightMode);
63
+ if (result.status !== 0) return result;
64
+ }
45
65
  return { status: 0, output: "" };
46
66
  }
47
67
  if (report.decision === "unknown") {
@@ -115,7 +135,8 @@ async function dispatchAction(action, target, platform, json, preflightMode = "f
115
135
  }
116
136
  case "wait-for-bridge": {
117
137
  const leaf = path.join(runnerDir, "adapters/mobile/wait-for-bridge.sh");
118
- return spawnScriptStreaming(leaf, ["--target", cwd], target);
138
+ const extra = action.argv ?? [];
139
+ return spawnScriptStreaming(leaf, ["--target", cwd, "--platform", platform, ...extra], target);
119
140
  }
120
141
  case "clear-metro-cache": {
121
142
  const leaf = path.join(runnerDir, "adapters/mobile/start-metro.sh");
@@ -313,5 +313,6 @@ async function computeMobileReadiness(resolved, options, fast) {
313
313
  };
314
314
  }
315
315
  export {
316
- decideMobileReadiness
316
+ decideMobileReadiness,
317
+ launchActions
317
318
  };
package/dist/adapters.js CHANGED
@@ -2,7 +2,7 @@ import http from "node:http";
2
2
  import { compatibilityMode, fixtureSummary, repoShape } from "./doctor.js";
3
3
  import { runLiveAdapterScript } from "./live-adapter-contract.js";
4
4
  import { withExtensionPage } from "../library/actions/extension/platform/cdp.mjs";
5
- import { bridgeCommand, evalSync, selectBridgeStatusEntry, simulatorScreenshot } from "../library/actions/mobile/platform/bridge.mjs";
5
+ import { bridgeCommand, evalSync, MOBILE_BRIDGE_ERROR_CODES, selectBridgeStatusEntry, simulatorScreenshot } from "../library/actions/mobile/platform/bridge.mjs";
6
6
  function sleep(ms) {
7
7
  return new Promise((resolve) => setTimeout(resolve, ms));
8
8
  }
@@ -331,9 +331,21 @@ async function handleMobileHud(payload, context) {
331
331
  return mobileHudSkippedOrThrow(error, { nodeId: hud.nodeId, status: hud.status });
332
332
  }
333
333
  }
334
+ async function hideMobileHudOnTeardown(projectRoot, env = {}) {
335
+ if (process.env.METAMASK_RECIPE_AUTO_HUD === "0" || process.env.METAMASK_RECIPE_AUTO_HUD === "false") return;
336
+ const input = {
337
+ node: { bridge_timeout_ms: 8e3, cdp_timeout_ms: 5e3 },
338
+ context: { nodeId: "teardown", projectRoot, artifactsDir: projectRoot, env }
339
+ };
340
+ try {
341
+ await bridgeCommand(input, ["hide-step"]);
342
+ } catch {
343
+ }
344
+ }
334
345
  function mobileHudSkippedOrThrow(error, extra = {}) {
335
346
  const reason = traceText(error);
336
- if (isMobileHudLifecycleSkip(reason)) {
347
+ const code = isRecord(error) && typeof error.code === "string" ? error.code : void 0;
348
+ if (isMobileHudLifecycleSkip(reason, code)) {
337
349
  process.stderr.write(`app.hud skipped (bridge target down or transitioning): ${reason}
338
350
  `);
339
351
  return {
@@ -346,20 +358,30 @@ function mobileHudSkippedOrThrow(error, extra = {}) {
346
358
  }
347
359
  throw new Error(`app.hud bridge command failed outside a lifecycle/no-target transition: ${reason}`);
348
360
  }
349
- function isMobileHudLifecycleSkip(reason) {
361
+ const HUD_LIFECYCLE_SKIP_CODES = /* @__PURE__ */ new Set([
362
+ MOBILE_BRIDGE_ERROR_CODES.NO_TARGET,
363
+ MOBILE_BRIDGE_ERROR_CODES.CDP_TIMEOUT,
364
+ MOBILE_BRIDGE_ERROR_CODES.WS_CLOSED,
365
+ MOBILE_BRIDGE_ERROR_CODES.METRO_UNREACHABLE
366
+ ]);
367
+ function isMobileHudLifecycleSkip(reason, code) {
368
+ if (code && HUD_LIFECYCLE_SKIP_CODES.has(code)) return true;
369
+ const haystack = reason.toLowerCase();
350
370
  return [
351
371
  "no responding bridge target",
352
- "Pinned android device",
372
+ "pinned android device",
353
373
  "bridge probe failed",
354
- "ECONNREFUSED",
374
+ "econnrefused",
355
375
  "timed out",
356
- "Timed out",
357
- "No React Native bridge target",
376
+ "no react native bridge target",
377
+ // Metro down mid-run: HUD cannot paint, so skip rather than abort (the real
378
+ // action fails on its own). Matches the METRO_UNREACHABLE code branch above.
379
+ "cannot reach metro",
358
380
  // Dev-client foreground/reload kills the Hermes page mid-flight: pending
359
381
  // CDP calls surface as a message timeout or a closed socket.
360
- "CDP message timeout",
361
- "WebSocket closed"
362
- ].some((needle) => reason.includes(needle));
382
+ "cdp message timeout",
383
+ "websocket closed"
384
+ ].some((needle) => haystack.includes(needle));
363
385
  }
364
386
  function animatedFlag(payload) {
365
387
  return payload.animated === true ? "--animated" : "--no-animated";
@@ -511,5 +533,6 @@ function targetProbeUrl(platform, targetPort) {
511
533
  export {
512
534
  createMetaMaskAdapters,
513
535
  createMetaMaskUiTransport,
536
+ hideMobileHudOnTeardown,
514
537
  isMobileHudLifecycleSkip
515
538
  };
@@ -6,8 +6,10 @@ import { loadActionManifest } from "../manifest.js";
6
6
  import { importRecipeProtocol } from "../paths.js";
7
7
  import { recipeRunning } from "../heal-bounds.js";
8
8
  import { EXIT } from "./shared.js";
9
+ import { describeManifestActions, fuzzyResolveActions } from "./manifest.js";
9
10
  import {
10
11
  parseArgs,
12
+ isRecord,
11
13
  optionFlag,
12
14
  optionString,
13
15
  resolveAdapter,
@@ -153,6 +155,82 @@ Artifacts: ${result.artifactManifestPath}`);
153
155
  }
154
156
  return result.status === "pass" ? EXIT.ok : EXIT.runtime;
155
157
  }
158
+ async function handleCallHelp(argv, genericHelp) {
159
+ const { action: shortName, rest } = parseCallArgs(argv.filter((arg) => arg !== "--help" && arg !== "-h"));
160
+ const { options } = parseArgs(rest, "call");
161
+ let adapter;
162
+ let manifest;
163
+ try {
164
+ ({ adapter } = resolveAdapter(options));
165
+ manifest = loadActionManifest(adapter, optionString(options, "actionManifest"));
166
+ } catch {
167
+ process.stdout.write(`${genericHelp}
168
+ `);
169
+ return EXIT.ok;
170
+ }
171
+ const matches = shortName ? fuzzyResolveActions(describeManifestActions(manifest), shortName) : [];
172
+ if (matches.length === 0) {
173
+ process.stdout.write(`${genericHelp}
174
+ `);
175
+ if (shortName) {
176
+ process.stdout.write(
177
+ `
178
+ No action matches "${shortName}" for the ${adapter} adapter.
179
+ Next: mm-harness actions --adapter ${adapter} # list the action vocabulary
180
+ `
181
+ );
182
+ }
183
+ return EXIT.ok;
184
+ }
185
+ for (const entry of matches) process.stdout.write(renderCallActionHelp(entry));
186
+ process.stdout.write(`${genericHelp}
187
+ `);
188
+ return EXIT.ok;
189
+ }
190
+ function renderCallActionHelp(entry) {
191
+ const short = entry.name.split(".").pop() ?? entry.name;
192
+ const schema = isRecord(entry.schema) ? entry.schema : {};
193
+ const properties = isRecord(schema.properties) ? schema.properties : {};
194
+ const required = new Set(
195
+ Array.isArray(schema.required) ? schema.required.filter((r) => typeof r === "string") : []
196
+ );
197
+ const lines = [`mm-harness call ${entry.name} [--arg k=v ...] [flags]`, ""];
198
+ if (entry.description) lines.push(` ${entry.description}`, "");
199
+ const names = Object.keys(properties).sort();
200
+ if (names.length === 0) {
201
+ lines.push(" Fields: (none)");
202
+ } else {
203
+ lines.push(" Fields (pass with --arg <name>=<value>):");
204
+ const width = Math.max(...names.map((name) => name.length));
205
+ for (const name of names) {
206
+ const prop = isRecord(properties[name]) ? properties[name] : {};
207
+ const type = typeof prop.type === "string" ? prop.type : "any";
208
+ const req = required.has(name) ? " (required)" : "";
209
+ const desc = typeof prop.description === "string" ? ` \u2014 ${prop.description}` : "";
210
+ const enumVals = Array.isArray(prop.enum) ? ` [one of: ${prop.enum.join(", ")}]` : "";
211
+ lines.push(` ${name.padEnd(width)} ${type}${req}${desc}${enumVals}`);
212
+ }
213
+ }
214
+ const examples = renderCallExamples(short, entry.examples);
215
+ if (examples.length > 0) {
216
+ lines.push("", " Examples:");
217
+ for (const example of examples) lines.push(` ${example}`);
218
+ }
219
+ return `${lines.join("\n")}
220
+
221
+ `;
222
+ }
223
+ function renderCallExamples(short, examples) {
224
+ if (!Array.isArray(examples)) return [];
225
+ const out = [];
226
+ for (const example of examples.slice(0, 2)) {
227
+ const node = isRecord(example) && isRecord(example.node) ? example.node : void 0;
228
+ if (!node) continue;
229
+ const tokens = Object.entries(node).filter(([key]) => key !== "action" && key !== "intent").map(([key, value]) => `--arg ${key}=${typeof value === "string" ? value : JSON.stringify(value)}`);
230
+ out.push(`mm-harness call ${short} ${tokens.join(" ")}`.trim());
231
+ }
232
+ return out;
233
+ }
156
234
  function parseCallArgs(argv) {
157
235
  const args = {};
158
236
  const rest = [];
@@ -191,5 +269,6 @@ function pickCallExampleAction(names) {
191
269
  return names[0] ?? "command";
192
270
  }
193
271
  export {
194
- handleCall
272
+ handleCall,
273
+ handleCallHelp
195
274
  };
@@ -87,6 +87,8 @@ function describeManifestAction(name, kind, metadata) {
87
87
  };
88
88
  }
89
89
  export {
90
+ describeManifestActions,
91
+ fuzzyResolveActions,
90
92
  handleActions,
91
93
  handleManifest
92
94
  };
@@ -68,7 +68,15 @@ async function runRecipe(adapter, recipe, artifactsDir, projectRoot, actionManif
68
68
  recordVideo: useFramedExtensionRecording ? false : recordVideo,
69
69
  ...runtimeOptions.librarySources ? { librarySources: runtimeOptions.librarySources } : {}
70
70
  };
71
- const result = await runner.run(runRequest);
71
+ let result;
72
+ try {
73
+ result = await runner.run(runRequest);
74
+ } finally {
75
+ if (adapter === "mobile") {
76
+ const { hideMobileHudOnTeardown } = await import("../adapters.js");
77
+ await hideMobileHudOnTeardown(projectRoot, recipeRunEnv(adapter, runtimeOptions));
78
+ }
79
+ }
72
80
  await stopRecipeRecording(recording, result);
73
81
  return result;
74
82
  } finally {
@@ -5,6 +5,7 @@ import { fileURLToPath } from "node:url";
5
5
  import { Command } from "commander";
6
6
  import { color } from "./cli-color.js";
7
7
  import { handleUpdate, maybeNudge } from "./commands/update.js";
8
+ import { handleCallHelp } from "./commands/call.js";
8
9
  const packageRoot = path.resolve(path.dirname(fileURLToPath(import.meta.url)), "..");
9
10
  globalThis.__MM_HARNESS_WRAPPER__ = true;
10
11
  const { main: recipeMain } = await import("./cli.js");
@@ -614,6 +615,13 @@ function hasPassthroughHelp(argv) {
614
615
  if (divider === -1) return false;
615
616
  return argv.slice(divider + 1).some((arg) => arg === "-h" || arg === "--help");
616
617
  }
618
+ function isCallActionHelp(argv) {
619
+ if (argv[0] !== "call") return false;
620
+ if (!argv[1] || argv[1].startsWith("-")) return false;
621
+ const divider = argv.indexOf("--");
622
+ const scope = divider === -1 ? argv : argv.slice(0, divider);
623
+ return scope.includes("--help") || scope.includes("-h");
624
+ }
617
625
  if (rawArgv.length === 0) {
618
626
  process.stdout.write(groupedHelp());
619
627
  process.exit(0);
@@ -621,4 +629,8 @@ if (rawArgv.length === 0) {
621
629
  if (hasPassthroughHelp(rawArgv)) {
622
630
  process.exit(await delegate(rawArgv));
623
631
  }
632
+ if (isCallActionHelp(rawArgv)) {
633
+ const callHelp = REAL.find((command) => command.name === "call")?.helpText ?? "";
634
+ process.exit(await handleCallHelp(rawArgv.slice(1), callHelp));
635
+ }
624
636
  await program.parseAsync(process.argv);
@@ -3,9 +3,35 @@ import { execFile, spawn } from 'node:child_process';
3
3
  import { promisify } from 'node:util';
4
4
  import path from 'node:path';
5
5
  import { fileURLToPath } from 'node:url';
6
+ import bridgeErrors from '../../../../adapters/mobile/bridge-runtime/lib/bridge-errors.cjs';
7
+
8
+ const {
9
+ BRIDGE_ERROR_CODES,
10
+ ERROR_CODE_BY_EXIT_CODE,
11
+ classifyBridgeErrorMessage,
12
+ coded,
13
+ parseErrorMarker,
14
+ } = bridgeErrors;
15
+
16
+ // Re-exported so the TS adapter classifies on the same code constants without a
17
+ // second import path into the cjs bridge-runtime.
18
+ export const MOBILE_BRIDGE_ERROR_CODES = BRIDGE_ERROR_CODES;
6
19
 
7
20
  const execFileAsync = promisify(execFile);
8
21
 
22
+ // Recover the typed code a cdp-bridge child reported: the stderr marker is
23
+ // authoritative (it carries the class the bridge classified at the source), the
24
+ // coded exit status is the fallback for output-less failures, and a raw message
25
+ // needle covers a bridge that predates the codes.
26
+ function bridgeChildErrorCode(exitCode, output) {
27
+ return (
28
+ parseErrorMarker(output) ||
29
+ ERROR_CODE_BY_EXIT_CODE[exitCode] ||
30
+ classifyBridgeErrorMessage(output) ||
31
+ null
32
+ );
33
+ }
34
+
9
35
  function sleep(ms) {
10
36
  return new Promise((resolve) => setTimeout(resolve, ms));
11
37
  }
@@ -169,12 +195,12 @@ export function selectBridgeStatusEntry(status, input) {
169
195
  }
170
196
  // Pinned android but no android entry answered — failing fast beats letting the
171
197
  // caller read the iOS entry and report misleading wallet state.
172
- throw new Error(
198
+ throw coded(new Error(
173
199
  `Pinned android device ${adbSerial} has no responding bridge target.\n` +
174
200
  ` Bridge status entries:\n` +
175
201
  entries.map((entry) => ` - ${entry.deviceName ?? '?'} [${entry.platform || 'unknown'}]`).join('\n') +
176
202
  `\n Next: mm-harness launch android # bring the app back to the foreground`,
177
- );
203
+ ), BRIDGE_ERROR_CODES.NO_TARGET);
178
204
  }
179
205
  if (iosSimulator) {
180
206
  const sim = entries.find((entry) => entry.deviceName === iosSimulator);
@@ -239,11 +265,16 @@ export async function bridgeCommand(input, args) {
239
265
  });
240
266
  if (result.timedOut) {
241
267
  const command = ['node', path.relative(input.context.projectRoot, script), ...redactBridgeArgs(args)].join(' ');
242
- throw new Error(`Mobile CDP bridge command timed out after ${result.timeoutMs}ms: ${command}`);
268
+ throw coded(
269
+ new Error(`Mobile CDP bridge command timed out after ${result.timeoutMs}ms: ${command}`),
270
+ BRIDGE_ERROR_CODES.CDP_TIMEOUT,
271
+ );
243
272
  }
244
273
  if (result.exitCode !== 0) {
245
274
  const command = ['node', path.relative(input.context.projectRoot, script), ...redactBridgeArgs(args)].join(' ');
246
- throw new Error(`Mobile CDP bridge command failed: ${command}\n${redactBridgeOutput(result.stderr || result.stdout, sensitiveBridgeArgs(args))}`);
275
+ const code = bridgeChildErrorCode(result.exitCode, result.stderr || result.stdout);
276
+ const error = new Error(`Mobile CDP bridge command failed: ${command}\n${redactBridgeOutput(result.stderr || result.stdout, sensitiveBridgeArgs(args))}`);
277
+ throw code ? coded(error, code) : error;
247
278
  }
248
279
  // For transient commands, stdout of '' or 'undefined' means the state is not yet
249
280
  // settled (e.g. mid-navigation). Return null so callers like waitForRoute can poll
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@deeeed/metamask-harness",
3
- "version": "0.12.0",
3
+ "version": "0.13.0",
4
4
  "type": "module",
5
5
  "bin": {
6
6
  "mm-harness": "bin/mm-harness"