@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 +19 -0
- package/adapters/mobile/start-metro.sh +80 -36
- package/adapters/mobile/wait-for-bridge.sh +15 -1
- 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 +35 -9
- package/dist/commands/fixtures.js +13 -1
- package/dist/commands/launch/index.js +35 -7
- package/dist/commands/run-engine.js +1 -0
- 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 +19 -0
- package/library/actions/mobile/platform/bridge.mjs +29 -7
- package/library/actions/mobile/wallet/ensure_unlocked.mjs +66 -8
- package/library/manifests/mobile.action-manifest.json +18 -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 +15 -9
- package/package.json +2 -2
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
|
-
|
|
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
|
}
|
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,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
|
|
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
|
|
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 =
|
|
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");
|
|
@@ -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
|
};
|
package/dist/mm-harness-cli.js
CHANGED
|
@@ -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,
|