@deeeed/metamask-harness 0.9.1 → 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 +30 -0
- package/adapters/mobile/bridge-runtime/lib/target-discovery.cjs +24 -16
- package/adapters/mobile/start-metro.sh +80 -36
- package/adapters/mobile/wait-for-bridge.sh +15 -1
- package/dist/adapters/extension/runtime.js +2 -3
- package/dist/adapters.js +41 -18
- package/dist/app-lifecycle.js +72 -0
- package/dist/cli-commands.js +1 -1
- package/dist/commands/device-target.js +39 -9
- package/dist/commands/fixtures.js +13 -1
- package/dist/commands/launch/index.js +35 -7
- package/dist/commands/run-engine.js +40 -12
- package/dist/commands/run.js +1 -2
- package/dist/live-adapter-contract.js +2 -3
- package/dist/mm-harness-cli.js +1 -0
- package/dist/paths.js +8 -1
- package/dist/runner.js +10 -4
- package/docs/CLI-SPEC.md +2 -1
- package/docs/recipe-libraries.md +203 -0
- package/library/actions/mobile/platform/bridge.mjs +91 -4
- package/library/actions/mobile/wallet/ensure_unlocked.mjs +69 -32
- package/library/actions/mobile/wallet/read_state.mjs +3 -24
- package/library/actions/mobile/wallet/setup.mjs +9 -27
- package/library/manifests/extension.action-manifest.json +13 -0
- package/library/manifests/mobile.action-manifest.json +31 -0
- package/library/recipes/app-lifecycle-android-smoke.mobile.recipe.json +87 -0
- package/library/recipes/perps-performance-background-resume.mobile.recipe.json +72 -0
- package/library/recipes/perps-performance-cold-start.mobile.recipe.json +72 -0
- package/library/recipes/perps-performance-warm-start.mobile.recipe.json +64 -0
- package/library/recipes/perps-performance.mobile.recipe.json +56 -0
- package/package.json +2 -2
package/CHANGELOG.md
CHANGED
|
@@ -1,5 +1,35 @@
|
|
|
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
|
+
|
|
22
|
+
## 0.10.0 - 2026-07-07
|
|
23
|
+
|
|
24
|
+
### Added
|
|
25
|
+
- **Canonical `perps-performance` measured flow** (`library/recipes/perps-performance.mobile.recipe.json`): unlock → open the Perps market list → read live state → open a market detail — one node per user-visible step with stable node names, so the per-node `duration`s in `trace.json` are the timings you monitor. Run pinned: `mm-harness run perps-performance --device <serial> --heal off`. Device-proven end-to-end on a physical Pixel.
|
|
26
|
+
- **`run <name>` resolves personal/team recipe libraries.** Previously only the packaged library was probed by name; custom recipes ran by path. Sources resolve in library-precedence order (personal > team shadow the packaged canonical — a same-named personal recipe wins), path-shaped args never probe libraries, and a miss teaches which sources were searched. Zero-flag default: `$FARMSLOT_HOME/recipe-library`.
|
|
27
|
+
- **Custom-library walkthrough** ("Your own measured flow" in `docs/recipe-libraries.md`): a peer engineer scaffolds a personal library, copies the canonical flow, retargets the nodes to their journey, and runs it by name. Every step is executed by the `perps-performance-recipe` contract test, so the doc cannot drift from reality.
|
|
28
|
+
- **`call` declared on mobile and extension manifests** (was core-only) with self-discovery metadata — personal `flows/` segments are now usable from mobile/extension recipes. Honest limitation documented: flows carry no adapter dimension in the protocol yet, so a cross-adapter `call` fails at live-run rather than plan time.
|
|
29
|
+
|
|
30
|
+
### Fixed
|
|
31
|
+
- **A `--device` pin wins target selection end-to-end on dual-platform slots.** Three independently sufficient holes let a pinned android run drive the iOS simulator (observed live): the discovery simulator filter ran before the android pin and the slot's ambient `IOS_SIMULATOR` captured the candidate set; pins were only enforced when more than one candidate existed (a single WRONG candidate was silently accepted); and the wallet actions' status-entry selectors checked the ambient simulator identity first while never matching serials against Metro device names. Fixed at every layer with live-repro contract cases; an unmatchable pin fails fast listing the Metro candidates.
|
|
32
|
+
|
|
3
33
|
## 0.9.1 - 2026-07-07
|
|
4
34
|
|
|
5
35
|
### Fixed
|
|
@@ -114,9 +114,24 @@ async function discoverTarget(port) {
|
|
|
114
114
|
/bridgeless|hermes/i.test(t.description || '')),
|
|
115
115
|
);
|
|
116
116
|
|
|
117
|
-
//
|
|
117
|
+
// Android pin identities (loaded before the simulator filter so an explicit
|
|
118
|
+
// android pin can take precedence over an ambient simulator name):
|
|
119
|
+
// ANDROID_TARGET_DEVICE_NAME — Metro-compatible model prefix resolved by bridge.mjs
|
|
120
|
+
// from the adb serial via `adb -s <serial> shell getprop ro.product.model`.
|
|
121
|
+
// Example: "Pixel 6" matches Metro deviceName "Pixel 6 - 16 - API 36".
|
|
122
|
+
// ANDROID_DEVICE — exact Metro deviceName (backward compat / user-specified).
|
|
123
|
+
const androidTargetName = loadAndroidTargetDeviceName();
|
|
124
|
+
const androidDevice = loadAndroidDevice();
|
|
125
|
+
const adbSerial = process.env.ADB_SERIAL || process.env.ANDROID_SERIAL || '';
|
|
126
|
+
const androidPinned = Boolean(androidTargetName || androidDevice);
|
|
127
|
+
|
|
128
|
+
// Filter by simulator name if IOS_SIMULATOR is set — but never when an android
|
|
129
|
+
// pin is present: dual-platform slots carry an ambient IOS_SIMULATOR in their
|
|
130
|
+
// context, and letting it win here sends a pinned android action to the iOS
|
|
131
|
+
// target. No-match keeps the full candidate set (ambient sim configs tolerate a
|
|
132
|
+
// sim that is not currently attached).
|
|
118
133
|
const simName = loadSimulatorName();
|
|
119
|
-
if (simName && candidates.length > 1) {
|
|
134
|
+
if (simName && !androidPinned && candidates.length > 1) {
|
|
120
135
|
const deviceFiltered = candidates.filter(
|
|
121
136
|
(t) => t.deviceName === simName,
|
|
122
137
|
);
|
|
@@ -125,20 +140,13 @@ async function discoverTarget(port) {
|
|
|
125
140
|
}
|
|
126
141
|
}
|
|
127
142
|
|
|
128
|
-
// Android
|
|
129
|
-
//
|
|
130
|
-
//
|
|
131
|
-
//
|
|
132
|
-
//
|
|
133
|
-
//
|
|
134
|
-
|
|
135
|
-
// back to another target (e.g. an iOS simulator). If the pin cannot be matched,
|
|
136
|
-
// fail fast with diagnostics so the operator can diagnose the mismatch.
|
|
137
|
-
const androidTargetName = loadAndroidTargetDeviceName();
|
|
138
|
-
const androidDevice = loadAndroidDevice();
|
|
139
|
-
const adbSerial = process.env.ADB_SERIAL || process.env.ANDROID_SERIAL || '';
|
|
140
|
-
|
|
141
|
-
if (candidates.length > 1) {
|
|
143
|
+
// Android pin enforcement — at ANY candidate count. A pin gated on
|
|
144
|
+
// candidates.length > 1 silently accepted a single WRONG candidate (observed
|
|
145
|
+
// live: the pinned Pixel's target dropped off Metro while the iOS target
|
|
146
|
+
// remained; the pin was ignored and the recipe drove the simulator). When a
|
|
147
|
+
// device is pinned, we MUST NOT silently fall back to another target; an
|
|
148
|
+
// unmatchable pin fails fast with diagnostics.
|
|
149
|
+
if (androidPinned && candidates.length > 0) {
|
|
142
150
|
let androidFiltered = [];
|
|
143
151
|
|
|
144
152
|
if (androidTargetName) {
|
|
@@ -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
|
-
|
|
120
|
-
|
|
121
|
-
|
|
122
|
-
|
|
123
|
-
|
|
124
|
-
|
|
125
|
-
|
|
126
|
-
|
|
127
|
-
|
|
128
|
-
|
|
129
|
-
|
|
130
|
-
|
|
131
|
-
|
|
132
|
-
|
|
133
|
-
|
|
134
|
-
|
|
135
|
-
|
|
136
|
-
|
|
137
|
-
|
|
138
|
-
|
|
139
|
-
|
|
140
|
-
|
|
141
|
-
|
|
142
|
-
|
|
143
|
-
|
|
144
|
-
|
|
145
|
-
|
|
146
|
-
|
|
147
|
-
|
|
148
|
-
|
|
149
|
-
|
|
150
|
-
|
|
151
|
-
|
|
152
|
-
|
|
153
|
-
|
|
154
|
-
|
|
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
|
-
|
|
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
|
}
|
|
@@ -275,9 +275,8 @@ function runProcess(command, args, options) {
|
|
|
275
275
|
if (settled) return;
|
|
276
276
|
settled = true;
|
|
277
277
|
child.kill("SIGTERM");
|
|
278
|
-
setTimeout(() =>
|
|
279
|
-
|
|
280
|
-
}, 1e3);
|
|
278
|
+
const killTimer = setTimeout(() => child.kill("SIGKILL"), 1e3);
|
|
279
|
+
child.once("close", () => clearTimeout(killTimer));
|
|
281
280
|
resolve({ exitCode: null, stdout, stderr, timedOut: true });
|
|
282
281
|
}, options.timeoutMs) : void 0;
|
|
283
282
|
child.stdout.on("data", (chunk) => {
|
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
|
|
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:
|
|
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)
|
|
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
|
-
|
|
333
|
-
|
|
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
|
+
};
|
package/dist/cli-commands.js
CHANGED
|
@@ -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,12 +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
|
|
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;
|
|
26
|
+
process.env.IOS_SIMULATOR = "";
|
|
7
27
|
}
|
|
8
|
-
function setIosDeviceEnv(id) {
|
|
9
|
-
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;
|
|
31
|
+
process.env.ADB_SERIAL = "";
|
|
32
|
+
process.env.ANDROID_SERIAL = "";
|
|
33
|
+
process.env.ANDROID_DEVICE = "";
|
|
34
|
+
process.env.ANDROID_TARGET_DEVICE_NAME = "";
|
|
10
35
|
}
|
|
11
36
|
function formatConnectedDevices(devices) {
|
|
12
37
|
return devices.map((d) => ` - ${d.id}${d.name ? ` (${d.name})` : ""} [${d.state}] ${d.platform}`).join("\n");
|
|
@@ -26,7 +51,7 @@ function deviceSelected(device) {
|
|
|
26
51
|
if (device.platform === "android") {
|
|
27
52
|
return process.env.ADB_SERIAL === device.id || process.env.ANDROID_SERIAL === device.id;
|
|
28
53
|
}
|
|
29
|
-
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;
|
|
30
55
|
}
|
|
31
56
|
function isTargetable(device) {
|
|
32
57
|
if (device.platform === "android") return device.state === "device";
|
|
@@ -50,19 +75,24 @@ function applyDeviceTargeting(command, adapter, options, opts) {
|
|
|
50
75
|
const connected2 = listConnectedDevices();
|
|
51
76
|
const androidById = connected2.find((d) => d.platform === "android" && d.id === device);
|
|
52
77
|
if (androidById) {
|
|
53
|
-
setAndroidDeviceEnv(device);
|
|
78
|
+
setAndroidDeviceEnv(device, androidById.name);
|
|
54
79
|
return { ok: true };
|
|
55
80
|
}
|
|
56
81
|
const iosById = connected2.find((d) => d.platform === "ios" && d.id === device);
|
|
57
82
|
if (iosById) {
|
|
58
|
-
setIosDeviceEnv(device);
|
|
83
|
+
setIosDeviceEnv(device, iosById.name);
|
|
59
84
|
return { ok: true };
|
|
60
85
|
}
|
|
61
|
-
const
|
|
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
|
+
});
|
|
62
92
|
if (byName.length === 1) {
|
|
63
93
|
const matched = byName[0];
|
|
64
|
-
if (matched.platform === "android") setAndroidDeviceEnv(matched.id);
|
|
65
|
-
else setIosDeviceEnv(matched.id);
|
|
94
|
+
if (matched.platform === "android") setAndroidDeviceEnv(matched.id, matched.name);
|
|
95
|
+
else setIosDeviceEnv(matched.id, matched.name);
|
|
66
96
|
return { ok: true };
|
|
67
97
|
}
|
|
68
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 =
|
|
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
|
-
|
|
172
|
-
|
|
173
|
-
|
|
174
|
-
|
|
175
|
-
|
|
176
|
-
|
|
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");
|