@deeeed/metamask-harness 0.11.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.
Files changed (65) hide show
  1. package/CHANGELOG.md +24 -0
  2. package/adapters/core/cleanup.sh +0 -0
  3. package/adapters/core/inject.sh +0 -0
  4. package/adapters/extension/cleanup.mjs +0 -0
  5. package/adapters/extension/ensure-browser.sh +0 -0
  6. package/adapters/extension/inject.mjs +0 -0
  7. package/adapters/extension/launch-browser.cjs +0 -0
  8. package/adapters/extension/launch.sh +0 -0
  9. package/adapters/extension/live.sh +0 -0
  10. package/adapters/extension/readiness.mjs +0 -0
  11. package/adapters/extension/reattach.sh +0 -0
  12. package/adapters/extension/refresh-build.sh +0 -0
  13. package/adapters/extension/seed-fixture.sh +0 -0
  14. package/adapters/extension/sidepanel-toggle.sh +0 -0
  15. package/adapters/extension/snapshot-dist.sh +0 -0
  16. package/adapters/extension/start-watch.sh +0 -0
  17. package/adapters/extension/verify.sh +0 -0
  18. package/adapters/extension/wallet-fixture-state.cjs +0 -0
  19. package/adapters/mobile/bridge-runtime/cdp-bridge.cjs +63 -0
  20. package/adapters/mobile/bridge-runtime/console-forwarder.cjs +341 -0
  21. package/adapters/mobile/bridge-runtime/lib/bridge-errors.cjs +106 -0
  22. package/adapters/mobile/bridge-runtime/lib/console-format.cjs +53 -0
  23. package/adapters/mobile/bridge-runtime/lib/match-bridge-target.cjs +82 -0
  24. package/adapters/mobile/bridge-runtime/lib/target-discovery.cjs +48 -42
  25. package/adapters/mobile/bridge-runtime/lib/ws-client.cjs +10 -5
  26. package/adapters/mobile/bridge-runtime/setup-wallet.sh +0 -0
  27. package/adapters/mobile/cleanup.sh +4 -0
  28. package/adapters/mobile/inject.sh +0 -0
  29. package/adapters/mobile/lib/metro-listener.sh +0 -0
  30. package/adapters/mobile/lib/tmux-viewer.sh +0 -0
  31. package/adapters/mobile/open-device.sh +0 -0
  32. package/adapters/mobile/prewarm-bundle.sh +0 -0
  33. package/adapters/mobile/start-metro.sh +37 -0
  34. package/adapters/mobile/stop-metro.sh +21 -0
  35. package/adapters/mobile/verify.sh +0 -0
  36. package/adapters/mobile/wait-for-bridge.sh +41 -23
  37. package/adapters/mobile/yarn-setup.sh +0 -0
  38. package/adapters/shared/activate-repo-node.sh +0 -0
  39. package/adapters/shared/activate-repo-ruby.sh +0 -0
  40. package/adapters/shared/cli-ux.sh +0 -0
  41. package/adapters/shared/ensure-runner-deps.sh +0 -0
  42. package/adapters/shared/harness-path.sh +0 -0
  43. package/adapters/shared/hash-helpers.sh +0 -0
  44. package/adapters/shared/json-field.sh +0 -0
  45. package/adapters/shared/open-debug.mjs +5 -0
  46. package/adapters/shared/open-log-window.sh +0 -0
  47. package/adapters/shared/reap-checkout-metros.sh +0 -0
  48. package/adapters/shared/resolve-farmslot-ports.mjs +0 -0
  49. package/adapters/shared/resolve-farmslot-ports.sh +0 -0
  50. package/adapters/shared/resolve-slot-ports.mjs +0 -0
  51. package/adapters/shared/resolve-slot-ports.sh +0 -0
  52. package/adapters/shared/sync-wallet-fixture.sh +0 -0
  53. package/adapters/shared/tmux-session.sh +0 -0
  54. package/dist/adapters/mobile/prepare.js +23 -2
  55. package/dist/adapters/mobile/runtime-decision.js +2 -1
  56. package/dist/adapters.js +39 -9
  57. package/dist/commands/call.js +80 -1
  58. package/dist/commands/manifest.js +2 -0
  59. package/dist/commands/run-engine.js +9 -1
  60. package/dist/mm-harness-cli.js +12 -0
  61. package/dist/runner.js +2 -2
  62. package/library/actions/mobile/platform/bridge.mjs +35 -4
  63. package/package.json +2 -2
  64. package/scripts/completions.sh +0 -0
  65. package/scripts/install-completions.sh +0 -0
@@ -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);
@@ -78,6 +79,30 @@ async function probeTargetDetailed(wsUrl) {
78
79
  }
79
80
  }
80
81
 
82
+ /**
83
+ * Filter Metro /json/list targets to debugger-capable RN/Hermes pages and rank
84
+ * them JS-runtime-first. Pre-RN-0.81 titles say "React Native"/"Hermes";
85
+ * RN 0.81+ Bridgeless titles are bundle-id-only (e.g. "io.metamask.MetaMask
86
+ * (mm-5)") with the runtime kind in `description`. Each device exposes
87
+ * multiple pages — page 1 is the native C++ runtime; the JS runtime (console,
88
+ * __AGENTIC__) has a higher page number, so after the sort the first candidate
89
+ * per device is its JS runtime.
90
+ */
91
+ function rankRuntimeCandidates(targets) {
92
+ const candidates = (Array.isArray(targets) ? targets : []).filter(
93
+ (t) =>
94
+ t.webSocketDebuggerUrl &&
95
+ ((t.title && (/react/i.test(t.title) || /hermes/i.test(t.title))) ||
96
+ /bridgeless|hermes/i.test(t.description || '')),
97
+ );
98
+ candidates.sort((a, b) => {
99
+ const aPage = Number.parseInt((a.id || '').split('-').pop() || '0', 10);
100
+ const bPage = Number.parseInt((b.id || '').split('-').pop() || '0', 10);
101
+ return bPage - aPage;
102
+ });
103
+ return candidates;
104
+ }
105
+
81
106
  /**
82
107
  * Discover the Hermes CDP WebSocket URL from Metro's /json/list endpoint.
83
108
  *
@@ -93,26 +118,18 @@ async function discoverTarget(port) {
93
118
  try {
94
119
  targets = await fetchJSON(listUrl);
95
120
  } catch (e) {
96
- throw new Error(
97
- `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,
98
124
  );
99
125
  }
100
126
 
101
127
  if (!Array.isArray(targets) || targets.length === 0) {
102
- 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);
103
129
  }
104
130
 
105
131
  // Filter to React Native / Hermes targets with a WebSocket URL
106
- let candidates = targets.filter(
107
- (t) =>
108
- t.webSocketDebuggerUrl &&
109
- // Pre-RN-0.81 titles say "React Native"/"Hermes". RN 0.81+ Bridgeless
110
- // titles are bundle-id-only (e.g. "io.metamask.MetaMask (mm-5)") and
111
- // the runtime kind is in `description` instead.
112
- ((t.title &&
113
- (/react/i.test(t.title) || /hermes/i.test(t.title))) ||
114
- /bridgeless|hermes/i.test(t.description || '')),
115
- );
132
+ let candidates = rankRuntimeCandidates(targets);
116
133
 
117
134
  // Android pin identities (loaded before the simulator filter so an explicit
118
135
  // android pin can take precedence over an ambient simulator name):
@@ -166,25 +183,25 @@ async function discoverTarget(port) {
166
183
  const ambiguousList = androidFiltered
167
184
  .map((t) => ` deviceName=${JSON.stringify(t.deviceName || '')} ws=${t.webSocketDebuggerUrl || ''}`)
168
185
  .join('\n');
169
- throw new Error(
186
+ throw coded(new Error(
170
187
  `Pinned Android device is ambiguous: model '${androidTargetName}' matches ${androidFiltered.length} Metro targets.\n` +
171
188
  ` Requested --device (ADB_SERIAL): ${adbSerial || '(not set)'}\n` +
172
189
  ` Matching Metro targets:\n${ambiguousList}\n` +
173
190
  ` Set ANDROID_DEVICE to the exact Metro deviceName to disambiguate.`,
174
- );
191
+ ), BRIDGE_ERROR_CODES.NO_TARGET);
175
192
  }
176
193
  if (androidFiltered.length === 0) {
177
194
  // Pinned device could not be matched — never silently pick another target.
178
195
  const candidateList = targets
179
196
  .map((t) => ` deviceName=${JSON.stringify(t.deviceName || '')} ws=${t.webSocketDebuggerUrl || ''}`)
180
197
  .join('\n');
181
- throw new Error(
198
+ throw coded(new Error(
182
199
  `Pinned Android device did not match any Metro target.\n` +
183
200
  ` Requested --device (ADB_SERIAL): ${adbSerial || '(not set)'}\n` +
184
201
  ` Resolved model (ANDROID_TARGET_DEVICE_NAME): ${androidTargetName}\n` +
185
202
  ` ANDROID_DEVICE: ${androidDevice || '(not set)'}\n` +
186
203
  ` Metro /json/list candidates:\n${candidateList}`,
187
- );
204
+ ), BRIDGE_ERROR_CODES.NO_TARGET);
188
205
  }
189
206
  candidates = androidFiltered;
190
207
  } else if (androidDevice) {
@@ -196,11 +213,11 @@ async function discoverTarget(port) {
196
213
  const candidateList = targets
197
214
  .map((t) => ` deviceName=${JSON.stringify(t.deviceName || '')} ws=${t.webSocketDebuggerUrl || ''}`)
198
215
  .join('\n');
199
- throw new Error(
216
+ throw coded(new Error(
200
217
  `Pinned Android device (ANDROID_DEVICE='${androidDevice}') did not match any Metro target.\n` +
201
218
  ` ADB_SERIAL: ${adbSerial || '(not set)'}\n` +
202
219
  ` Metro /json/list candidates:\n${candidateList}`,
203
- );
220
+ ), BRIDGE_ERROR_CODES.NO_TARGET);
204
221
  }
205
222
  candidates = androidFiltered;
206
223
  }
@@ -211,12 +228,16 @@ async function discoverTarget(port) {
211
228
  }
212
229
 
213
230
  if (candidates.length === 0) {
214
- throw new Error(
215
- `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,
216
234
  );
217
235
  }
218
236
 
219
- // Sort by page number descending (JS runtime has higher page number than C++ native)
237
+ // Sort by page number descending (JS runtime has higher page number than C++
238
+ // native). rankRuntimeCandidates output arrives pre-sorted; this re-sort is
239
+ // load-bearing only when the raw targets.filter() fallback above repopulated
240
+ // the candidate list.
220
241
  candidates.sort((a, b) => {
221
242
  const aPage = Number.parseInt((a.id || '').split('-').pop() || '0', 10);
222
243
  const bPage = Number.parseInt((b.id || '').split('-').pop() || '0', 10);
@@ -245,28 +266,13 @@ async function discoverAllTargets(port) {
245
266
  try {
246
267
  targets = await fetchJSON(listUrl);
247
268
  } catch (e) {
248
- throw new Error(
249
- `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,
250
272
  );
251
273
  }
252
274
 
253
- const candidates = (targets || []).filter(
254
- (t) =>
255
- t.webSocketDebuggerUrl &&
256
- // Pre-RN-0.81 titles say "React Native"/"Hermes". RN 0.81+ Bridgeless
257
- // titles are bundle-id-only (e.g. "io.metamask.MetaMask (mm-5)") and
258
- // the runtime kind is in `description` instead.
259
- ((t.title &&
260
- (/react/i.test(t.title) || /hermes/i.test(t.title))) ||
261
- /bridgeless|hermes/i.test(t.description || '')),
262
- );
263
-
264
- // Sort by page number descending (JS runtime has higher page number)
265
- candidates.sort((a, b) => {
266
- const aPage = Number.parseInt((a.id || '').split('-').pop() || '0', 10);
267
- const bPage = Number.parseInt((b.id || '').split('-').pop() || '0', 10);
268
- return bPage - aPage;
269
- });
275
+ const candidates = rankRuntimeCandidates(targets);
270
276
 
271
277
  // Group by deviceName, probe each to find the JS runtime target. Prefer the
272
278
  // device's __AGENTIC__-bearing target; when a device has none, keep its first
@@ -297,4 +303,4 @@ async function discoverAllTargets(port) {
297
303
  return results;
298
304
  }
299
305
 
300
- module.exports = { discoverTarget, discoverAllTargets };
306
+ module.exports = { discoverTarget, discoverAllTargets, rankRuntimeCandidates };
@@ -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
  };
File without changes
@@ -45,6 +45,10 @@ if ! command -v harness_root >/dev/null 2>&1; then
45
45
  exit 1
46
46
  fi
47
47
  HARNESS_DIR="$(harness_dir "$TARGET" mobile)"
48
+
49
+ # A console-forwarder left running would keep polling for this checkout's Metro
50
+ # after cleanup orphans it; stop any forwarder scoped to this checkout first.
51
+ pkill -f "console-forwarder.cjs --port .* --out $TARGET/" 2>/dev/null || true
48
52
  if GIT_BACKUP_PATH="$(git -C "$TARGET" rev-parse --git-path recipe-harness/mobile/backup 2>/dev/null)"; then
49
53
  case "$GIT_BACKUP_PATH" in
50
54
  /*) BACKUP_DIR="$GIT_BACKUP_PATH" ;;
File without changes
File without changes
File without changes
File without changes
File without changes
@@ -77,6 +77,34 @@ default_metro_workers() {
77
77
  # shellcheck disable=SC1091
78
78
  . "$SCRIPT_DIR/lib/tmux-viewer.sh"
79
79
 
80
+ # Bridgeless RN gates console→Metro forwarding on console._isPolyfilled, which is
81
+ # false with the Hermes native console — device logs (incl. DevLogger) never reach
82
+ # metro.log. The forwarder attaches over CDP and appends them back. Kill switch:
83
+ # METAMASK_RECIPE_CONSOLE_FORWARD=0.
84
+ start_console_forwarder() {
85
+ local forwarder="$SCRIPT_DIR/bridge-runtime/console-forwarder.cjs"
86
+ local fwd_pid_file="$LOG_DIR/console-forwarder.pid"
87
+ [ "${METAMASK_RECIPE_CONSOLE_FORWARD:-1}" != "0" ] || return 0
88
+ { [ -f "$forwarder" ] && command -v node >/dev/null 2>&1; } || return 0
89
+ if [ -f "$fwd_pid_file" ]; then
90
+ local old_fwd
91
+ old_fwd="$(cat "$fwd_pid_file" 2>/dev/null || true)"
92
+ # A crash can leave a stale pid file and the OS can recycle the pid for an
93
+ # unrelated process — only kill when the command line is really ours.
94
+ if [ -n "$old_fwd" ]; then
95
+ case "$(ps -p "$old_fwd" -o command= 2>/dev/null)" in
96
+ *console-forwarder.cjs*) kill "$old_fwd" 2>/dev/null ;;
97
+ esac
98
+ fi
99
+ rm -f "$fwd_pid_file"
100
+ fi
101
+ nohup node "$forwarder" --port "$PORT" --out "$LOG_FILE" \
102
+ </dev/null >> "$LOG_DIR/console-forwarder.err" 2>&1 &
103
+ echo "$!" > "$fwd_pid_file"
104
+ disown 2>/dev/null || true
105
+ printf 'Device console → metro.log (CDP forwarder pid %s)\n' "$(cat "$fwd_pid_file")" >&2
106
+ }
107
+
80
108
  write_metro_runner() {
81
109
  local runner="$1"
82
110
  local clear_flag=""
@@ -92,6 +120,9 @@ set -a
92
120
  [ ! -f .env.local ] || . ./.env.local
93
121
  set +a
94
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:-}"
95
126
  export EXPO_NO_TYPESCRIPT_SETUP=1
96
127
  export WATCHER_PORT="$(printf '%q' "$PORT")" METRO_PORT="$(printf '%q' "$PORT")"
97
128
  export METRO_MAX_WORKERS="$(printf '%q' "$METRO_WORKERS")"
@@ -137,6 +168,7 @@ if metro_ready; then
137
168
  rm -f "$PID_FILE"
138
169
  elif metro_listener_valid; then
139
170
  printf 'Metro already running and valid on port %s\n' "$PORT" >&2
171
+ start_console_forwarder
140
172
  exit 0
141
173
  else
142
174
  stop_metro_listener || exit 1
@@ -174,6 +206,10 @@ if ! start_metro_tmux; then
174
206
  # rejects. Default to the main dev client (what runway installs / launch targets)
175
207
  # so already-installed slots work without re-syncing fixtures.
176
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:-}"
177
213
  export EXPO_NO_TYPESCRIPT_SETUP=1
178
214
  export WATCHER_PORT="${PORT}" METRO_PORT="${PORT}"
179
215
  export METRO_MAX_WORKERS="${METRO_WORKERS}"
@@ -225,6 +261,7 @@ if [ "$READY" = true ]; then
225
261
  first_ready_pid="${ready_pids%% *}"
226
262
  [ -z "$first_ready_pid" ] || printf '%s\n' "$first_ready_pid" > "$PID_FILE"
227
263
  printf 'Metro ready on port %s\n' "$PORT" >&2
264
+ start_console_forwarder
228
265
  printf ' Next: mm-harness logs (tail Metro)\n' >&2
229
266
  exit 0
230
267
  fi
@@ -62,6 +62,27 @@ fi
62
62
  set +e
63
63
  rm -f "$PID_FILE"
64
64
 
65
+ # start-metro runs a per-checkout console-forwarder holding the device debugger
66
+ # slot; Metro going down must take it too, or the orphan keeps polling and later
67
+ # fights a fresh forwarder over the slot. Kill the pid this LOG_DIR recorded,
68
+ # then sweep forwarders started under a different RECIPE_RUNTIME_DIR of this
69
+ # same checkout (their pid files live elsewhere; match on the --out path).
70
+ FWD_PID_FILE="$LOG_DIR/console-forwarder.pid"
71
+ fwd_pid="$(cat "$FWD_PID_FILE" 2>/dev/null || true)"
72
+ # A crash can leave a stale pid file and the OS can recycle the pid for an
73
+ # unrelated process — only kill when the command line is really ours.
74
+ if [ -n "$fwd_pid" ]; then
75
+ case "$(ps -p "$fwd_pid" -o command= 2>/dev/null)" in
76
+ *console-forwarder.cjs*)
77
+ if kill "$fwd_pid" 2>/dev/null; then
78
+ printf 'Stopped console forwarder (pid %s)\n' "$fwd_pid" >&2
79
+ fi
80
+ ;;
81
+ esac
82
+ fi
83
+ rm -f "$FWD_PID_FILE"
84
+ pkill -f "console-forwarder.cjs --port .* --out $TARGET/" 2>/dev/null
85
+
65
86
  # Port-scoped stop above misses bundlers a prior launch left on a DIFFERENT port
66
87
  # (port drift / missing pid file). Sweep every Metro bound to this checkout so
67
88
  # stop fully cleans up — the leak that stacked bundlers across relaunches.
File without changes
@@ -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
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
@@ -126,6 +126,11 @@ async function tryDevSettingsCdpFallback(numericPort, normalizedAction) {
126
126
  : 'NativeModules.DevSettings.openDebugger()';
127
127
  const result = spawnSync(process.execPath, [bridgePath, 'eval', expression], {
128
128
  encoding: 'utf8',
129
+ // cdp-bridge resolves its debugger-slot lock (RECIPE_RUNTIME_DIR) against
130
+ // cwd; anchor it to the app checkout so the console forwarder sees the
131
+ // yield — APP_ROOT when a caller exports it, else the cwd mm-harness debug
132
+ // already set to the target.
133
+ cwd: process.env.APP_ROOT || process.cwd(),
129
134
  env: { ...process.env, WATCHER_PORT: String(numericPort) },
130
135
  timeout: 15000,
131
136
  });
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
@@ -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
  };