@deeeed/metamask-harness 0.10.0 → 0.11.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,5 +1,24 @@
1
1
  # Changelog
2
2
 
3
+ ## 0.11.0 - 2026-07-07
4
+
5
+ ### Added
6
+ - Mobile recipes can now use the standard outer `app.lifecycle` action for deterministic performance start states without rebuilding (Android background/foreground/terminate/restart, iOS simulator launch/terminate/restart through the shared Farmslot lifecycle adapter).
7
+ - `fixtures set --device <serial|udid|name>` now pins mobile fixture sync to the requested device using the same targeting rules as `run`, `call`, and `doctor`.
8
+ - New lifecycle-controlled measured flows in the packaged library: `app-lifecycle-android-smoke`, `perps-performance-warm-start`, `perps-performance-background-resume`, `perps-performance-cold-start`; performance recipes now keep lifecycle/unlock in `setup[]`/`startState` so measured nodes carry only CUF timings.
9
+
10
+ ### Changed
11
+ - `@farmslot/recipe-harness` dependency raised to `^0.4.0` (ships the `app.lifecycle` adapter — the `FARMSLOT_ROOT` local-source fallback is no longer needed at runtime).
12
+
13
+ ### Fixed
14
+ - `launch ios --device <udid|name>` again accepts a shutdown simulator target: launch preserves the requested simulator identity and lets `open-device.sh` boot it instead of requiring it to appear in the booted-device list first.
15
+ - iOS UDID pins are exported as both `SIM_UDID` and simulator name where available, so downstream simulator tooling can use the stable name while preserving the exact pin.
16
+ - `metamask.wallet.ensure_unlocked` now waits for the pinned agentic bridge target, tolerates transient status drops during the unlocked stability check, and avoids the old full password-unlock fallback for brief bridge gaps.
17
+ - Automatic `app.hud` updates are best-effort only while the mobile bridge target is down during app lifecycle transitions; ordinary HUD bridge failures now fail loudly.
18
+ - Mobile `cdp.target` required checks only pass when the responding target has `agenticPresent === true`, avoiding false positives from non-instrumented React Native targets.
19
+ - Mobile Metro startup is hardened around tmux/PID ownership: stale PID files and dead tmux windows are cleaned before reuse, and Metro survives the launching shell.
20
+ - `wait-for-bridge` now matches the selected Android target with the same boundary-safe device-name rules as device targeting and waits for the selected target, not just any target on the Metro port.
21
+
3
22
  ## 0.10.0 - 2026-07-07
4
23
 
5
24
  ### Added
@@ -77,6 +77,44 @@ default_metro_workers() {
77
77
  # shellcheck disable=SC1091
78
78
  . "$SCRIPT_DIR/lib/tmux-viewer.sh"
79
79
 
80
+ write_metro_runner() {
81
+ local runner="$1"
82
+ local clear_flag=""
83
+ [ "$CLEAR" = true ] && clear_flag=' --clear'
84
+ cat > "$runner" <<EOF
85
+ #!/usr/bin/env bash
86
+ set -uo pipefail
87
+ cd "$(printf '%q' "$TARGET")"
88
+ : > "$(printf '%q' "$LOG_FILE")"
89
+ set -a
90
+ [ ! -f .js.env ] || . ./.js.env
91
+ [ ! -f .env ] || . ./.env
92
+ [ ! -f .env.local ] || . ./.env.local
93
+ set +a
94
+ [ -n "\${METAMASK_BUILD_TYPE:-}" ] || export METAMASK_BUILD_TYPE=main
95
+ export EXPO_NO_TYPESCRIPT_SETUP=1
96
+ export WATCHER_PORT="$(printf '%q' "$PORT")" METRO_PORT="$(printf '%q' "$PORT")"
97
+ export METRO_MAX_WORKERS="$(printf '%q' "$METRO_WORKERS")"
98
+ exec yarn expo start --port "$(printf '%q' "$PORT")"$clear_flag >> "$(printf '%q' "$LOG_FILE")" 2>&1
99
+ EOF
100
+ chmod +x "$runner"
101
+ }
102
+
103
+ start_metro_tmux() {
104
+ command -v tmux >/dev/null 2>&1 || return 1
105
+ local session window runner
106
+ session="$(resolve_run_tmux_session "$LOG_DIR")"
107
+ { [ -n "$session" ] && tmux has-session -t "=$session" 2>/dev/null; } || return 1
108
+ window="metro-${PORT}"
109
+ runner="$LOG_DIR/run-metro-${PORT}.sh"
110
+ write_metro_runner "$runner"
111
+ tmux kill-window -t "${session}:${window}" >/dev/null 2>&1 || true
112
+ tmux new-window -d -t "$session" -n "$window" "exec $(printf '%q' "$runner")" || return 1
113
+ printf '%s:%s\n' "$session" "$window" > "$LOG_DIR/metro.tmux"
114
+ printf 'Metro server → tmux %s:%s\n' "$session" "$window" >&2
115
+ return 0
116
+ }
117
+
80
118
  # --- main ---------------------------------------------------------------------
81
119
 
82
120
  # Detect stale metro.pid: if the pid file names a dead process, clean it up.
@@ -116,42 +154,45 @@ printf 'Starting Metro on port %s (workers=%s%s)\n' \
116
154
  "$PORT" "$METRO_WORKERS" "$CLEAR_LABEL" >&2
117
155
  printf '(Full log: %s — mm-harness logs | mm-harness logs --full)\n' "$LOG_FILE" >&2
118
156
 
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"
157
+ if ! start_metro_tmux; then
158
+ # Metro runs detached, writing to the log. The tmux window is a read-only tail.
159
+ (
160
+ cd "$TARGET"
161
+ : > "$LOG_FILE"
162
+ # Source product env files (provides MM_INFURA_PROJECT_ID and friends).
163
+ set -a
164
+ # shellcheck disable=SC1091
165
+ [ ! -f .js.env ] || . ./.js.env
166
+ # shellcheck disable=SC1091
167
+ [ ! -f .env ] || . ./.env
168
+ # shellcheck disable=SC1091
169
+ [ ! -f .env.local ] || . ./.env.local
170
+ set +a
171
+ # The quick-launch runs `expo start` directly, bypassing scripts/build.sh which
172
+ # passes the build type as an argument (start:ios = build.sh ios main dev). The
173
+ # fixture .js.env ships METAMASK_BUILD_TYPE="" (empty), which Metro's transform
174
+ # rejects. Default to the main dev client (what runway installs / launch targets)
175
+ # so already-installed slots work without re-syncing fixtures.
176
+ [ -n "${METAMASK_BUILD_TYPE:-}" ] || export METAMASK_BUILD_TYPE=main
177
+ export EXPO_NO_TYPESCRIPT_SETUP=1
178
+ export WATCHER_PORT="${PORT}" METRO_PORT="${PORT}"
179
+ export METRO_MAX_WORKERS="${METRO_WORKERS}"
180
+ # shellcheck disable=SC2094
181
+ # nohup sets SIGHUP to ignored (inherited across exec), so Metro survives the
182
+ # launching shell or tmux window closing; when this subshell exits the process
183
+ # is reparented to init. nohup.out is suppressed (stdout/stderr both go to
184
+ # $LOG_FILE via >>).
185
+ metro_args=(expo start --port "$PORT")
186
+ [ "$CLEAR" = true ] && metro_args+=(--clear)
187
+ nohup yarn "${metro_args[@]}" \
188
+ </dev/null >> "$LOG_FILE" 2>&1 &
189
+ metro_pid="$!"
190
+ echo "$metro_pid" > "$PID_FILE"
191
+ disown "$metro_pid" 2>/dev/null || true
192
+ )
193
+
194
+ start_viewer_window "$LOG_FILE"
195
+ fi
155
196
 
156
197
  # Attach a log-tui watcher for compact event display (best-effort; non-blocking).
157
198
  LOG_TUI="$(dirname "$0")/../shared/log-tui.mjs"
@@ -180,6 +221,9 @@ done
180
221
  [ -n "$TAIL_PID" ] && { kill "$TAIL_PID" 2>/dev/null; wait "$TAIL_PID" 2>/dev/null || true; }
181
222
 
182
223
  if [ "$READY" = true ]; then
224
+ ready_pids="$(metro_listener_pids)"
225
+ first_ready_pid="${ready_pids%% *}"
226
+ [ -z "$first_ready_pid" ] || printf '%s\n' "$first_ready_pid" > "$PID_FILE"
183
227
  printf 'Metro ready on port %s\n' "$PORT" >&2
184
228
  printf ' Next: mm-harness logs (tail Metro)\n' >&2
185
229
  exit 0
@@ -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
  }
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,44 @@ 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
+ return {
338
+ hud: false,
339
+ skipped: true,
340
+ warning: "app.hud skipped because the mobile bridge target is down during lifecycle transition.",
341
+ ...extra,
342
+ reason
343
+ };
344
+ }
345
+ throw new Error(`app.hud bridge command failed outside a lifecycle/no-target transition: ${reason}`);
346
+ }
347
+ function isMobileHudLifecycleSkip(reason) {
348
+ return [
349
+ "no responding bridge target",
350
+ "Pinned android device",
351
+ "bridge probe failed",
352
+ "ECONNREFUSED",
353
+ "timed out",
354
+ "Timed out",
355
+ "No React Native bridge target"
356
+ ].some((needle) => reason.includes(needle));
334
357
  }
335
358
  function animatedFlag(payload) {
336
359
  return payload.animated === true ? "--animated" : "--no-animated";
@@ -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);
@@ -25,6 +25,7 @@ import {
25
25
  } from "../../heal-bounds.js";
26
26
  import { extensionDepsBlock, extensionRuntimeReusable, launchExtension } from "./extension.js";
27
27
  import { launchMobile } from "./mobile.js";
28
+ import { applyDeviceTargeting } from "../device-target.js";
28
29
  const LAUNCH_BOOLEANS = /* @__PURE__ */ new Set([
29
30
  "build",
30
31
  "watch",
@@ -80,7 +81,10 @@ async function handleLaunch(argv) {
80
81
  );
81
82
  }
82
83
  }
83
- applyLaunchEnvOverrides(options, adapter, mobileTarget, target);
84
+ const envResult = applyLaunchEnvOverrides(options, adapter, mobileTarget, target);
85
+ if (envResult && "code" in envResult) {
86
+ return usageOut(json, "launch", envResult.message, "mm-harness launch android --device <adb-serial|device-name>");
87
+ }
84
88
  if (adapter === "extension" && typeof options["url"] === "string" && options["url"]) {
85
89
  process.env.EXTENSION_START_URL = options["url"];
86
90
  }
@@ -168,12 +172,32 @@ function applyLaunchEnvOverrides(options, adapter, mobileTarget, target) {
168
172
  getAdapterSurface(adapter).resolveSlotPorts(target);
169
173
  const device = str(options, "device");
170
174
  if (device && adapter === "mobile") {
171
- if (mobileTarget === "android") {
172
- process.env.ADB_SERIAL = device;
173
- process.env.ANDROID_SERIAL = device;
174
- process.env.ANDROID_DEVICE = device;
175
- } else {
176
- process.env.IOS_SIMULATOR = device;
175
+ const result = applyDeviceTargeting("launch", adapter, options, { gate: false, rerun: "" });
176
+ if ("code" in result) {
177
+ if (mobileTarget === "ios" && result.code === "DEVICE_NOT_FOUND") {
178
+ process.env.IOS_SIMULATOR = device;
179
+ process.env.SIM_UDID = isIosSimulatorUdid(device) ? device : "";
180
+ process.env.ADB_SERIAL = "";
181
+ process.env.ANDROID_SERIAL = "";
182
+ process.env.ANDROID_DEVICE = "";
183
+ process.env.ANDROID_TARGET_DEVICE_NAME = "";
184
+ } else {
185
+ return result;
186
+ }
187
+ }
188
+ if (mobileTarget === "android" && !process.env.ADB_SERIAL && !process.env.ANDROID_SERIAL) {
189
+ return {
190
+ ok: false,
191
+ code: "DEVICE_WRONG_PLATFORM",
192
+ message: `--device ${device} did not resolve to an Android device for launch android.`
193
+ };
194
+ }
195
+ if (mobileTarget === "ios" && !process.env.IOS_SIMULATOR) {
196
+ return {
197
+ ok: false,
198
+ code: "DEVICE_WRONG_PLATFORM",
199
+ message: `--device ${device} did not resolve to an iOS simulator for launch ios.`
200
+ };
177
201
  }
178
202
  }
179
203
  const cdpPort = str(options, "cdpPort");
@@ -187,6 +211,10 @@ function applyLaunchEnvOverrides(options, adapter, mobileTarget, target) {
187
211
  process.env.RECIPE_WATCHER_PORT = watcherPort;
188
212
  process.env.METRO_PORT = watcherPort;
189
213
  }
214
+ return { ok: true };
215
+ }
216
+ function isIosSimulatorUdid(value) {
217
+ return /^[0-9A-Fa-f-]{36}$/u.test(value);
190
218
  }
191
219
  function nativeInputsChanged(target, adapter) {
192
220
  const baselineFile = recipeRuntimePath(target, ".last-build-ref");
@@ -97,6 +97,7 @@ function recipeRunEnv(adapter, runtimeOptions = {}) {
97
97
  METRO_PORT: process.env.METRO_PORT ?? process.env.WATCHER_PORT ?? base.CDP_PORT ?? base.RECIPE_CDP_PORT,
98
98
  IOS_SIMULATOR: process.env.IOS_SIMULATOR,
99
99
  ANDROID_DEVICE: process.env.ANDROID_DEVICE,
100
+ ANDROID_TARGET_DEVICE_NAME: process.env.ANDROID_TARGET_DEVICE_NAME,
100
101
  ADB_SERIAL: process.env.ADB_SERIAL,
101
102
  ANDROID_SERIAL: process.env.ANDROID_SERIAL
102
103
  };
@@ -411,6 +411,7 @@ Example:
411
411
  --extension-id-file <path> finalize: optional file to read/write the resolved extension id
412
412
  --adapter <mobile|extension> Target adapter (auto-detected inside a checkout)
413
413
  --target <path> Checkout path (default: cwd)
414
+ --device <udid|serial|name> Mobile only: target this device for sync/set
414
415
  --json Machine-readable output
415
416
 
416
417
  Example:
package/dist/paths.js CHANGED
@@ -113,6 +113,12 @@ async function importRecipeHarnessRuntimeCdp() {
113
113
  "packages/recipe-harness/src/runtime/cdp.ts"
114
114
  );
115
115
  }
116
+ async function importRecipeHarnessAppLifecycle() {
117
+ return importProtocolPackage(
118
+ "@farmslot/recipe-harness/adapters/app-lifecycle",
119
+ "packages/recipe-harness/src/adapters/app-lifecycle.ts"
120
+ );
121
+ }
116
122
  async function importRecipeHarnessRuntimeBrowserExtension() {
117
123
  return importProtocolPackage(
118
124
  "@farmslot/recipe-harness/runtime/browser-extension",
@@ -154,7 +160,7 @@ async function importProtocolPackage(packageName, localSourceEntry) {
154
160
  function isMissingPackageError(error, packageName) {
155
161
  if (!(error instanceof Error)) return false;
156
162
  const code = error.code;
157
- return code === "ERR_MODULE_NOT_FOUND" && error.message.includes(packageName);
163
+ return (code === "ERR_MODULE_NOT_FOUND" || code === "ERR_PACKAGE_PATH_NOT_EXPORTED") && error.message.includes(packageName);
158
164
  }
159
165
  export {
160
166
  DEFAULT_RECIPE_HARNESS_ROOT,
@@ -162,6 +168,7 @@ export {
162
168
  assertAdapter,
163
169
  extensionIdPath,
164
170
  importRecipeHarness,
171
+ importRecipeHarnessAppLifecycle,
165
172
  importRecipeHarnessCli,
166
173
  importRecipeHarnessRuntimeBrowserExtension,
167
174
  importRecipeHarnessRuntimeCdp,