@deeeed/metamask-harness 0.10.0 → 0.12.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 (72) hide show
  1. package/CHANGELOG.md +35 -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 +47 -0
  20. package/adapters/mobile/bridge-runtime/console-forwarder.cjs +343 -0
  21. package/adapters/mobile/bridge-runtime/lib/target-discovery.cjs +31 -29
  22. package/adapters/mobile/bridge-runtime/setup-wallet.sh +0 -0
  23. package/adapters/mobile/cleanup.sh +4 -0
  24. package/adapters/mobile/inject.sh +0 -0
  25. package/adapters/mobile/lib/metro-listener.sh +0 -0
  26. package/adapters/mobile/lib/tmux-viewer.sh +0 -0
  27. package/adapters/mobile/open-device.sh +0 -0
  28. package/adapters/mobile/prewarm-bundle.sh +0 -0
  29. package/adapters/mobile/start-metro.sh +110 -36
  30. package/adapters/mobile/stop-metro.sh +21 -0
  31. package/adapters/mobile/verify.sh +0 -0
  32. package/adapters/mobile/wait-for-bridge.sh +15 -1
  33. package/adapters/mobile/yarn-setup.sh +0 -0
  34. package/adapters/shared/activate-repo-node.sh +0 -0
  35. package/adapters/shared/activate-repo-ruby.sh +0 -0
  36. package/adapters/shared/cli-ux.sh +0 -0
  37. package/adapters/shared/ensure-runner-deps.sh +0 -0
  38. package/adapters/shared/harness-path.sh +0 -0
  39. package/adapters/shared/hash-helpers.sh +0 -0
  40. package/adapters/shared/json-field.sh +0 -0
  41. package/adapters/shared/open-debug.mjs +5 -0
  42. package/adapters/shared/open-log-window.sh +0 -0
  43. package/adapters/shared/reap-checkout-metros.sh +0 -0
  44. package/adapters/shared/resolve-farmslot-ports.mjs +0 -0
  45. package/adapters/shared/resolve-farmslot-ports.sh +0 -0
  46. package/adapters/shared/resolve-slot-ports.mjs +0 -0
  47. package/adapters/shared/resolve-slot-ports.sh +0 -0
  48. package/adapters/shared/sync-wallet-fixture.sh +0 -0
  49. package/adapters/shared/tmux-session.sh +0 -0
  50. package/dist/adapters.js +49 -19
  51. package/dist/app-lifecycle.js +72 -0
  52. package/dist/cli-commands.js +1 -1
  53. package/dist/commands/device-target.js +35 -9
  54. package/dist/commands/fixtures.js +13 -1
  55. package/dist/commands/launch/index.js +35 -7
  56. package/dist/commands/run-engine.js +1 -0
  57. package/dist/mm-harness-cli.js +1 -0
  58. package/dist/paths.js +8 -1
  59. package/dist/runner.js +10 -4
  60. package/docs/CLI-SPEC.md +2 -1
  61. package/docs/recipe-libraries.md +19 -0
  62. package/library/actions/mobile/platform/bridge.mjs +29 -7
  63. package/library/actions/mobile/wallet/ensure_unlocked.mjs +66 -8
  64. package/library/manifests/mobile.action-manifest.json +18 -0
  65. package/library/recipes/app-lifecycle-android-smoke.mobile.recipe.json +87 -0
  66. package/library/recipes/perps-performance-background-resume.mobile.recipe.json +72 -0
  67. package/library/recipes/perps-performance-cold-start.mobile.recipe.json +72 -0
  68. package/library/recipes/perps-performance-warm-start.mobile.recipe.json +64 -0
  69. package/library/recipes/perps-performance.mobile.recipe.json +15 -9
  70. package/package.json +3 -3
  71. package/scripts/completions.sh +0 -0
  72. package/scripts/install-completions.sh +0 -0
@@ -77,6 +77,72 @@ 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
+
108
+ write_metro_runner() {
109
+ local runner="$1"
110
+ local clear_flag=""
111
+ [ "$CLEAR" = true ] && clear_flag=' --clear'
112
+ cat > "$runner" <<EOF
113
+ #!/usr/bin/env bash
114
+ set -uo pipefail
115
+ cd "$(printf '%q' "$TARGET")"
116
+ : > "$(printf '%q' "$LOG_FILE")"
117
+ set -a
118
+ [ ! -f .js.env ] || . ./.js.env
119
+ [ ! -f .env ] || . ./.env
120
+ [ ! -f .env.local ] || . ./.env.local
121
+ set +a
122
+ [ -n "\${METAMASK_BUILD_TYPE:-}" ] || export METAMASK_BUILD_TYPE=main
123
+ export EXPO_NO_TYPESCRIPT_SETUP=1
124
+ export WATCHER_PORT="$(printf '%q' "$PORT")" METRO_PORT="$(printf '%q' "$PORT")"
125
+ export METRO_MAX_WORKERS="$(printf '%q' "$METRO_WORKERS")"
126
+ exec yarn expo start --port "$(printf '%q' "$PORT")"$clear_flag >> "$(printf '%q' "$LOG_FILE")" 2>&1
127
+ EOF
128
+ chmod +x "$runner"
129
+ }
130
+
131
+ start_metro_tmux() {
132
+ command -v tmux >/dev/null 2>&1 || return 1
133
+ local session window runner
134
+ session="$(resolve_run_tmux_session "$LOG_DIR")"
135
+ { [ -n "$session" ] && tmux has-session -t "=$session" 2>/dev/null; } || return 1
136
+ window="metro-${PORT}"
137
+ runner="$LOG_DIR/run-metro-${PORT}.sh"
138
+ write_metro_runner "$runner"
139
+ tmux kill-window -t "${session}:${window}" >/dev/null 2>&1 || true
140
+ tmux new-window -d -t "$session" -n "$window" "exec $(printf '%q' "$runner")" || return 1
141
+ printf '%s:%s\n' "$session" "$window" > "$LOG_DIR/metro.tmux"
142
+ printf 'Metro server → tmux %s:%s\n' "$session" "$window" >&2
143
+ return 0
144
+ }
145
+
80
146
  # --- main ---------------------------------------------------------------------
81
147
 
82
148
  # Detect stale metro.pid: if the pid file names a dead process, clean it up.
@@ -99,6 +165,7 @@ if metro_ready; then
99
165
  rm -f "$PID_FILE"
100
166
  elif metro_listener_valid; then
101
167
  printf 'Metro already running and valid on port %s\n' "$PORT" >&2
168
+ start_console_forwarder
102
169
  exit 0
103
170
  else
104
171
  stop_metro_listener || exit 1
@@ -116,42 +183,45 @@ printf 'Starting Metro on port %s (workers=%s%s)\n' \
116
183
  "$PORT" "$METRO_WORKERS" "$CLEAR_LABEL" >&2
117
184
  printf '(Full log: %s — mm-harness logs | mm-harness logs --full)\n' "$LOG_FILE" >&2
118
185
 
119
- # Metro runs detached, writing to the log. The tmux window is a read-only tail.
120
- (
121
- cd "$TARGET"
122
- : > "$LOG_FILE"
123
- # Source product env files (provides MM_INFURA_PROJECT_ID and friends).
124
- set -a
125
- # shellcheck disable=SC1091
126
- [ ! -f .js.env ] || . ./.js.env
127
- # shellcheck disable=SC1091
128
- [ ! -f .env ] || . ./.env
129
- # shellcheck disable=SC1091
130
- [ ! -f .env.local ] || . ./.env.local
131
- set +a
132
- # The quick-launch runs `expo start` directly, bypassing scripts/build.sh which
133
- # passes the build type as an argument (start:ios = build.sh ios main dev). The
134
- # fixture .js.env ships METAMASK_BUILD_TYPE="" (empty), which Metro's transform
135
- # rejects. Default to the main dev client (what runway installs / launch targets)
136
- # so already-installed slots work without re-syncing fixtures.
137
- [ -n "${METAMASK_BUILD_TYPE:-}" ] || export METAMASK_BUILD_TYPE=main
138
- export EXPO_NO_TYPESCRIPT_SETUP=1
139
- export WATCHER_PORT="${PORT}" METRO_PORT="${PORT}"
140
- export METRO_MAX_WORKERS="${METRO_WORKERS}"
141
- # shellcheck disable=SC2094
142
- # nohup sets SIGHUP to ignored (inherited across exec), so Metro survives the
143
- # launching shell or tmux window closing; when this subshell exits the process
144
- # is reparented to init. That alone is the detach guarantee Metro was never
145
- # in the parent shell's job table, so there is nothing to disown.
146
- # nohup.out is suppressed (stdout/stderr both go to $LOG_FILE via >>).
147
- metro_args=(expo start --port "$PORT")
148
- [ "$CLEAR" = true ] && metro_args+=(--clear)
149
- nohup yarn "${metro_args[@]}" \
150
- </dev/null >> "$LOG_FILE" 2>&1 &
151
- echo "$!" > "$PID_FILE"
152
- )
153
-
154
- start_viewer_window "$LOG_FILE"
186
+ if ! start_metro_tmux; then
187
+ # Metro runs detached, writing to the log. The tmux window is a read-only tail.
188
+ (
189
+ cd "$TARGET"
190
+ : > "$LOG_FILE"
191
+ # Source product env files (provides MM_INFURA_PROJECT_ID and friends).
192
+ set -a
193
+ # shellcheck disable=SC1091
194
+ [ ! -f .js.env ] || . ./.js.env
195
+ # shellcheck disable=SC1091
196
+ [ ! -f .env ] || . ./.env
197
+ # shellcheck disable=SC1091
198
+ [ ! -f .env.local ] || . ./.env.local
199
+ set +a
200
+ # The quick-launch runs `expo start` directly, bypassing scripts/build.sh which
201
+ # passes the build type as an argument (start:ios = build.sh ios main dev). The
202
+ # fixture .js.env ships METAMASK_BUILD_TYPE="" (empty), which Metro's transform
203
+ # rejects. Default to the main dev client (what runway installs / launch targets)
204
+ # so already-installed slots work without re-syncing fixtures.
205
+ [ -n "${METAMASK_BUILD_TYPE:-}" ] || export METAMASK_BUILD_TYPE=main
206
+ export EXPO_NO_TYPESCRIPT_SETUP=1
207
+ export WATCHER_PORT="${PORT}" METRO_PORT="${PORT}"
208
+ export METRO_MAX_WORKERS="${METRO_WORKERS}"
209
+ # shellcheck disable=SC2094
210
+ # nohup sets SIGHUP to ignored (inherited across exec), so Metro survives the
211
+ # launching shell or tmux window closing; when this subshell exits the process
212
+ # is reparented to init. nohup.out is suppressed (stdout/stderr both go to
213
+ # $LOG_FILE via >>).
214
+ metro_args=(expo start --port "$PORT")
215
+ [ "$CLEAR" = true ] && metro_args+=(--clear)
216
+ nohup yarn "${metro_args[@]}" \
217
+ </dev/null >> "$LOG_FILE" 2>&1 &
218
+ metro_pid="$!"
219
+ echo "$metro_pid" > "$PID_FILE"
220
+ disown "$metro_pid" 2>/dev/null || true
221
+ )
222
+
223
+ start_viewer_window "$LOG_FILE"
224
+ fi
155
225
 
156
226
  # Attach a log-tui watcher for compact event display (best-effort; non-blocking).
157
227
  LOG_TUI="$(dirname "$0")/../shared/log-tui.mjs"
@@ -180,7 +250,11 @@ done
180
250
  [ -n "$TAIL_PID" ] && { kill "$TAIL_PID" 2>/dev/null; wait "$TAIL_PID" 2>/dev/null || true; }
181
251
 
182
252
  if [ "$READY" = true ]; then
253
+ ready_pids="$(metro_listener_pids)"
254
+ first_ready_pid="${ready_pids%% *}"
255
+ [ -z "$first_ready_pid" ] || printf '%s\n' "$first_ready_pid" > "$PID_FILE"
183
256
  printf 'Metro ready on port %s\n' "$PORT" >&2
257
+ start_console_forwarder
184
258
  printf ' Next: mm-harness logs (tail Metro)\n' >&2
185
259
  exit 0
186
260
  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
@@ -93,7 +93,21 @@ const fs = require('fs');
93
93
  try {
94
94
  const value = JSON.parse(fs.readFileSync(process.argv[2], 'utf8'));
95
95
  const targets = Array.isArray(value) ? value : [value];
96
- if (!targets.some((t) => t && typeof t === 'object' && t.route)) process.exit(1);
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);
97
111
  } catch { process.exit(1); }
98
112
  NODE
99
113
  }
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
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, simulatorScreenshot } from "../library/actions/mobile/platform/bridge.mjs";
5
+ import { bridgeCommand, evalSync, 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
  }
@@ -106,6 +106,7 @@ function firstScalarText(record, keys, label, fallback) {
106
106
  }
107
107
  function traceText(value) {
108
108
  if (value === void 0 || value === null) return "";
109
+ if (value instanceof Error) return value.message;
109
110
  if (typeof value === "string" || typeof value === "number" || typeof value === "boolean") {
110
111
  return String(value);
111
112
  }
@@ -189,22 +190,10 @@ function uiInputFor(context, input) {
189
190
  }
190
191
  function mobileProbeOutput(status, input, projectRoot) {
191
192
  const entries = Array.isArray(status) ? status : [status];
192
- const preferredDevices = [
193
- input.node?.ios_simulator,
194
- input.node?.simulator,
195
- input.node?.android_device,
196
- input.node?.adb_serial,
197
- process.env.IOS_SIMULATOR,
198
- process.env.ANDROID_DEVICE,
199
- process.env.ADB_SERIAL
200
- ].filter((value) => typeof value === "string" && value.length > 0);
201
- const selected = entries.find((entry) => {
202
- if (!isRecord(entry)) return false;
203
- return typeof entry.deviceName === "string" && preferredDevices.includes(entry.deviceName);
204
- }) ?? entries.find((entry) => isRecord(entry) && isRecord(entry.route)) ?? null;
193
+ const selected = selectBridgeStatusEntry(status, input) ?? null;
205
194
  const route = isRecord(selected) && isRecord(selected.route) ? selected.route : null;
206
195
  return {
207
- reachable: Boolean(selected),
196
+ reachable: isRecord(selected) && selected.agenticPresent === true && Boolean(route),
208
197
  bridge: mobileBridgePath(projectRoot),
209
198
  targetCount: entries.filter((entry) => isRecord(entry)).length,
210
199
  deviceName: isRecord(selected) && typeof selected.deviceName === "string" ? selected.deviceName : null,
@@ -327,10 +316,50 @@ async function handleMobileWaitFor(payload, context) {
327
316
  }
328
317
  async function handleMobileHud(payload, context) {
329
318
  const input = mobileUiInput(context, "hud", payload);
330
- if (payload.clear === true) return bridgeCommand(input, ["hide-step"]);
319
+ if (payload.clear === true) {
320
+ try {
321
+ return await bridgeCommand(input, ["hide-step"]);
322
+ } catch (error) {
323
+ return mobileHudSkippedOrThrow(error);
324
+ }
325
+ }
331
326
  const hud = mobileHudPayload(payload, context);
332
- const result = await bridgeCommand(input, ["show-step-json", JSON.stringify(hud.step)]);
333
- return { hud: true, nodeId: hud.nodeId, status: hud.status, result };
327
+ try {
328
+ const result = await bridgeCommand(input, ["show-step-json", JSON.stringify(hud.step)]);
329
+ return { hud: true, nodeId: hud.nodeId, status: hud.status, result };
330
+ } catch (error) {
331
+ return mobileHudSkippedOrThrow(error, { nodeId: hud.nodeId, status: hud.status });
332
+ }
333
+ }
334
+ function mobileHudSkippedOrThrow(error, extra = {}) {
335
+ const reason = traceText(error);
336
+ if (isMobileHudLifecycleSkip(reason)) {
337
+ process.stderr.write(`app.hud skipped (bridge target down or transitioning): ${reason}
338
+ `);
339
+ return {
340
+ hud: false,
341
+ skipped: true,
342
+ warning: "app.hud skipped because the mobile bridge target is down during lifecycle transition.",
343
+ ...extra,
344
+ reason
345
+ };
346
+ }
347
+ throw new Error(`app.hud bridge command failed outside a lifecycle/no-target transition: ${reason}`);
348
+ }
349
+ function isMobileHudLifecycleSkip(reason) {
350
+ return [
351
+ "no responding bridge target",
352
+ "Pinned android device",
353
+ "bridge probe failed",
354
+ "ECONNREFUSED",
355
+ "timed out",
356
+ "Timed out",
357
+ "No React Native bridge target",
358
+ // Dev-client foreground/reload kills the Hermes page mid-flight: pending
359
+ // CDP calls surface as a message timeout or a closed socket.
360
+ "CDP message timeout",
361
+ "WebSocket closed"
362
+ ].some((needle) => reason.includes(needle));
334
363
  }
335
364
  function animatedFlag(payload) {
336
365
  return payload.animated === true ? "--animated" : "--no-animated";
@@ -481,5 +510,6 @@ function targetProbeUrl(platform, targetPort) {
481
510
  }
482
511
  export {
483
512
  createMetaMaskAdapters,
484
- createMetaMaskUiTransport
513
+ createMetaMaskUiTransport,
514
+ isMobileHudLifecycleSkip
485
515
  };
@@ -0,0 +1,72 @@
1
+ function resolveMetaMaskMobileLifecycleTarget(node, rawContext) {
2
+ const context = rawContext;
3
+ const platform = resolveMobilePlatform(node, context);
4
+ const port = scalar(node.watcher_port ?? node.metro_port, "app.lifecycle.metro_port") ?? context.env.WATCHER_PORT ?? context.env.METRO_PORT ?? process.env.WATCHER_PORT ?? process.env.METRO_PORT ?? "8081";
5
+ const launchUrl = scalar(node.launch_url ?? node.launchUrl ?? node.url, "app.lifecycle.launch_url") ?? expoDevClientUrl(port, platform);
6
+ if (platform === "android") {
7
+ return {
8
+ platform,
9
+ deviceId: scalar(node.adb_serial ?? node.android_device ?? node.device, "app.lifecycle.device") ?? context.env.ADB_SERIAL ?? context.env.ANDROID_SERIAL ?? process.env.ADB_SERIAL ?? process.env.ANDROID_SERIAL,
10
+ appId: scalar(node.package_id ?? node.packageName ?? node.app_id, "app.lifecycle.package_id") ?? context.env.ANDROID_PACKAGE_ID ?? process.env.ANDROID_PACKAGE_ID ?? "io.metamask",
11
+ launchUrl,
12
+ metroPort: port,
13
+ prelaunchCalls: expoBundlePrewarmCalls(port, "android")
14
+ };
15
+ }
16
+ return {
17
+ platform: "ios-simulator",
18
+ deviceId: scalar(node.simulator ?? node.ios_simulator ?? node.device, "app.lifecycle.device") ?? context.env.IOS_SIMULATOR ?? context.env.SIM_UDID ?? process.env.IOS_SIMULATOR ?? process.env.SIM_UDID ?? "booted",
19
+ appId: scalar(node.bundle_id ?? node.bundleId ?? node.app_id, "app.lifecycle.bundle_id") ?? context.env.IOS_BUNDLE_ID ?? process.env.IOS_BUNDLE_ID ?? "io.metamask.MetaMask",
20
+ launchUrl,
21
+ prelaunchCalls: expoBundlePrewarmCalls(port, "ios")
22
+ };
23
+ }
24
+ function resolveMobilePlatform(node, context) {
25
+ const raw = scalar(node.platform, "app.lifecycle.platform") ?? context.env.PLATFORM ?? process.env.PLATFORM;
26
+ if (raw === "android" || raw === "ios") return raw;
27
+ if (node.adb_serial || node.android_device || context.env.ADB_SERIAL || context.env.ANDROID_SERIAL || process.env.ADB_SERIAL || process.env.ANDROID_SERIAL) {
28
+ return "android";
29
+ }
30
+ return "ios";
31
+ }
32
+ function expoDevClientUrl(port, platform) {
33
+ const scheme = (platform === "ios" ? process.env.IOS_DEV_CLIENT_SCHEME : process.env.ANDROID_DEV_CLIENT_SCHEME) ?? "expo-metamask";
34
+ const metroUrl = `http://localhost:${port}?disableOnboarding=1`;
35
+ return `${scheme}://expo-development-client/?url=${encodeURIComponent(metroUrl)}`;
36
+ }
37
+ function expoBundlePrewarmCalls(port, platform) {
38
+ const params = new URLSearchParams({
39
+ platform,
40
+ dev: "true",
41
+ hot: "false",
42
+ lazy: "true",
43
+ "transform.engine": "hermes",
44
+ "transform.bytecode": "1",
45
+ "transform.routerRoot": "app",
46
+ unstable_transformProfile: "hermes-stable"
47
+ });
48
+ return [
49
+ {
50
+ file: "curl",
51
+ args: [
52
+ "-fsS",
53
+ "-o",
54
+ "/dev/null",
55
+ "--max-time",
56
+ "60",
57
+ `http://localhost:${port}/index.bundle?${params}`
58
+ ]
59
+ }
60
+ ];
61
+ }
62
+ function scalar(value, label) {
63
+ if (value == null) return void 0;
64
+ if (typeof value === "string" || typeof value === "number" || typeof value === "boolean") {
65
+ const text = String(value);
66
+ return text.length > 0 ? text : void 0;
67
+ }
68
+ throw new Error(`${label} must be a string, number, or boolean.`);
69
+ }
70
+ export {
71
+ resolveMetaMaskMobileLifecycleTarget
72
+ };
@@ -13,7 +13,7 @@ const SPEC = {
13
13
  { name: "sync", desc: "Refresh harness + canonicalize wallet fixture", flags: ["--json"] },
14
14
  { name: "logs", aliases: ["tail"], desc: "Compact build events or full log", flags: ["--full", "-f", "--window", "--events", "--source", "--json"] },
15
15
  { name: "debug", aliases: ["devtools", "inspect"], desc: "Open DevTools UI", flags: ["--json", "--no-open"] },
16
- { name: "fixtures", desc: "Manage the canonical wallet fixture (sync/set/generate)", args: ["sync", "set", "generate"], flags: ["--fixture", "--out", "--adapter", "--target", "--json"] },
16
+ { name: "fixtures", desc: "Manage the canonical wallet fixture (sync/set/generate)", args: ["sync", "set", "generate"], flags: ["--fixture", "--out", "--adapter", "--target", "--device", "--json"] },
17
17
  { name: "actions", desc: "List runnable recipe actions", flags: ["--json"] },
18
18
  { name: "doctor", desc: "Check harness/orchestration health", flags: ["--json", "--target", "--adapter", "--runtime-dir", "--expect-live", "--cdp-port", "--device"] },
19
19
  { name: "run", desc: "Execute a proof recipe (path or library name, e.g. run smoke)", args: ["recipe.json|name"], flags: ["--list", "--device"] },
@@ -1,16 +1,37 @@
1
+ import { execFileSync } from "node:child_process";
1
2
  import { listConnectedDevices } from "../devices.js";
2
3
  import { optionString } from "./parse-args.js";
3
- function setAndroidDeviceEnv(id) {
4
+ function normalizeDeviceName(value) {
5
+ return value.replace(/_/gu, " ").trim();
6
+ }
7
+ function resolveAndroidModel(serial) {
8
+ try {
9
+ const output = execFileSync("adb", ["-s", serial, "shell", "getprop", "ro.product.model"], {
10
+ encoding: "utf8",
11
+ stdio: ["ignore", "pipe", "ignore"],
12
+ timeout: 5e3
13
+ }).trim();
14
+ return output || void 0;
15
+ } catch {
16
+ return void 0;
17
+ }
18
+ }
19
+ function setAndroidDeviceEnv(id, fallbackName) {
4
20
  process.env.ADB_SERIAL = id;
5
21
  process.env.ANDROID_SERIAL = id;
6
22
  process.env.ANDROID_DEVICE = id;
23
+ const model = resolveAndroidModel(id) ?? (fallbackName ? normalizeDeviceName(fallbackName) : void 0);
24
+ if (model) process.env.ANDROID_TARGET_DEVICE_NAME = model;
25
+ else delete process.env.ANDROID_TARGET_DEVICE_NAME;
7
26
  process.env.IOS_SIMULATOR = "";
8
27
  }
9
- function setIosDeviceEnv(id) {
10
- process.env.IOS_SIMULATOR = id;
28
+ function setIosDeviceEnv(id, fallbackName) {
29
+ process.env.IOS_SIMULATOR = fallbackName ? normalizeDeviceName(fallbackName) : id;
30
+ process.env.SIM_UDID = id;
11
31
  process.env.ADB_SERIAL = "";
12
32
  process.env.ANDROID_SERIAL = "";
13
33
  process.env.ANDROID_DEVICE = "";
34
+ process.env.ANDROID_TARGET_DEVICE_NAME = "";
14
35
  }
15
36
  function formatConnectedDevices(devices) {
16
37
  return devices.map((d) => ` - ${d.id}${d.name ? ` (${d.name})` : ""} [${d.state}] ${d.platform}`).join("\n");
@@ -30,7 +51,7 @@ function deviceSelected(device) {
30
51
  if (device.platform === "android") {
31
52
  return process.env.ADB_SERIAL === device.id || process.env.ANDROID_SERIAL === device.id;
32
53
  }
33
- return process.env.IOS_SIMULATOR === device.id;
54
+ return process.env.IOS_SIMULATOR === device.id || process.env.SIM_UDID === device.id || process.env.IOS_SIMULATOR === device.name;
34
55
  }
35
56
  function isTargetable(device) {
36
57
  if (device.platform === "android") return device.state === "device";
@@ -54,19 +75,24 @@ function applyDeviceTargeting(command, adapter, options, opts) {
54
75
  const connected2 = listConnectedDevices();
55
76
  const androidById = connected2.find((d) => d.platform === "android" && d.id === device);
56
77
  if (androidById) {
57
- setAndroidDeviceEnv(device);
78
+ setAndroidDeviceEnv(device, androidById.name);
58
79
  return { ok: true };
59
80
  }
60
81
  const iosById = connected2.find((d) => d.platform === "ios" && d.id === device);
61
82
  if (iosById) {
62
- setIosDeviceEnv(device);
83
+ setIosDeviceEnv(device, iosById.name);
63
84
  return { ok: true };
64
85
  }
65
- const byName = connected2.filter((d) => d.name === device);
86
+ const normalizedDevice = normalizeDeviceName(device);
87
+ const byName = connected2.filter((d) => {
88
+ const name = d.name ?? "";
89
+ const normalizedName = normalizeDeviceName(name);
90
+ return name === device || normalizedName === normalizedDevice || d.platform === "android" && normalizedDevice.startsWith(`${normalizedName} -`);
91
+ });
66
92
  if (byName.length === 1) {
67
93
  const matched = byName[0];
68
- if (matched.platform === "android") setAndroidDeviceEnv(matched.id);
69
- else setIosDeviceEnv(matched.id);
94
+ if (matched.platform === "android") setAndroidDeviceEnv(matched.id, matched.name);
95
+ else setIosDeviceEnv(matched.id, matched.name);
70
96
  return { ok: true };
71
97
  }
72
98
  if (byName.length > 1) {
@@ -2,6 +2,7 @@ import fs from "node:fs";
2
2
  import path from "node:path";
3
3
  import { runnerDir, walletFixturePath } from "../paths.js";
4
4
  import { getAdapterSurface } from "../adapters/surface.js";
5
+ import { applyDeviceTargeting } from "./device-target.js";
5
6
  import { ADAPTER_DETECT_NEXT, EXIT, flag, parseFlags, resolveAdapter, scriptOverride, spawnScript, spawnScriptStreaming, str, targetOf, usageOut } from "./shared.js";
6
7
  const FIXTURES_BOOLEANS = /* @__PURE__ */ new Set(["json"]);
7
8
  const RECOVERABLE_SETUP_WALLET_PATTERNS = [
@@ -19,6 +20,13 @@ const RECOVERABLE_SETUP_WALLET_PATTERNS = [
19
20
  function isRecoverableSetupWalletFailure(output) {
20
21
  return RECOVERABLE_SETUP_WALLET_PATTERNS.some((p) => output.includes(p));
21
22
  }
23
+ function resolveMobileFixturePlatform(options) {
24
+ const explicit = str(options, "platform") ?? process.env["MOBILE_PLATFORM"];
25
+ if (explicit === "android" || explicit === "ios") return explicit;
26
+ if (process.env.ADB_SERIAL || process.env.ANDROID_SERIAL || process.env.ANDROID_DEVICE) return "android";
27
+ if (process.env.IOS_SIMULATOR) return "ios";
28
+ return "ios";
29
+ }
22
30
  async function handleFixtures(argv, deps) {
23
31
  const { positional, options } = parseFlags(argv, FIXTURES_BOOLEANS);
24
32
  const json = flag(options, "json");
@@ -31,6 +39,10 @@ async function handleFixtures(argv, deps) {
31
39
  if (!adapter) {
32
40
  return usageOut(json, "fixtures", `could not detect the MetaMask repo type for ${target}`, ADAPTER_DETECT_NEXT);
33
41
  }
42
+ const dtResult = applyDeviceTargeting("fixtures", adapter, options, { gate: false, rerun: "" });
43
+ if ("code" in dtResult) {
44
+ return usageOut(json, "fixtures", dtResult.message, "mm-harness fixtures set --adapter mobile --device <adb-serial|simulator-udid>");
45
+ }
34
46
  if (sub === "generate") return fixturesGenerate(adapter, target, options, json);
35
47
  if (sub === "finalize") return fixturesFinalize(adapter, target, options, json);
36
48
  const surface = getAdapterSurface(adapter);
@@ -64,7 +76,7 @@ async function handleFixtures(argv, deps) {
64
76
  process.env["RECIPE_SETUP_WALLET_RETRIED"] = "1";
65
77
  if (!json) process.stderr.write(" setup-wallet: recoverable failure \u2014 restarting Metro and retrying\n Next: wait for relaunch, then wallet setup will retry automatically\n");
66
78
  const { prepareMobile } = await import("../adapters/mobile/prepare.js");
67
- const platform = str(options, "platform") ?? process.env["MOBILE_PLATFORM"] ?? "ios";
79
+ const platform = resolveMobileFixturePlatform(options);
68
80
  const relaunchResult = await prepareMobile(target, { platform, json, preflightMode: "auto", clearMetro: true });
69
81
  if (relaunchResult.status === 0) {
70
82
  result = await spawnScriptStreaming(setupWalletSh, ["--fixture", fixturePath], target);