@deeeed/metamask-harness 0.33.3 → 0.34.1
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 +35 -0
- package/adapters/manifest.json +0 -8
- package/adapters/mobile/bridge-runtime/cdp-bridge.cjs +32 -5
- package/adapters/mobile/bridge-runtime/lib/target-discovery.cjs +91 -24
- package/adapters/mobile/launch-metro.cjs +6 -4
- package/adapters/mobile/open-device.sh +164 -15
- package/adapters/mobile/start-metro.sh +165 -93
- package/adapters/mobile/stop-metro.sh +1 -0
- package/dist/adapters/mobile/prepare.js +14 -0
- package/dist/adapters/mobile/video-recorder.js +284 -0
- package/dist/adapters.js +225 -35
- package/dist/command-contract.js +1 -0
- package/dist/commands/device-target.js +17 -6
- package/dist/commands/launch/index.js +36 -5
- package/dist/commands/launch/mobile.js +10 -3
- package/dist/commands/run-engine.js +34 -2
- package/dist/devices.js +9 -2
- package/dist/heal-bounds.js +10 -0
- package/dist/live-adapter-contract.js +7 -0
- package/dist/mm-harness-cli.js +4 -2
- package/dist/recipe-security.js +1 -0
- package/dist/recording-target.js +23 -5
- package/dist/runner.js +107 -6
- package/dist/runtime-context.js +33 -1
- package/docs/RECIPES.md +41 -0
- package/library/actions/mobile/app/network-control.mjs +135 -0
- package/library/actions/mobile/app/network.mjs +4 -0
- package/library/actions/mobile/perps/capture_performance.mjs +4 -0
- package/library/actions/mobile/perps/performance-capture.mjs +465 -0
- package/library/actions/mobile/perps/perps.mjs +16 -0
- package/library/actions/mobile/platform/bridge.mjs +121 -3
- package/library/actions/mobile/wallet/ensure_unlocked.mjs +56 -12
- package/library/actions/mobile/wallet/select_account.mjs +21 -17
- package/library/manifests/mobile.action-manifest.json +148 -1
- package/library/recipes/mobile/perps/performance.homepage.android-background-reconnect.recipe.json +159 -0
- package/library/recipes/mobile/perps/performance.homepage.android-background-short.recipe.json +157 -0
- package/library/recipes/mobile/perps/performance.homepage.android-cold-disk-cache.recipe.json +165 -0
- package/library/recipes/mobile/perps/performance.homepage.android-cold-no-cache.recipe.json +139 -0
- package/library/recipes/mobile/perps/performance.homepage.android-network-recovery.recipe.json +149 -0
- package/library/recipes/mobile/perps/performance.homepage.ios-background-reconnect.recipe.json +110 -0
- package/library/recipes/mobile/perps/performance.homepage.ios-background-short.recipe.json +108 -0
- package/library/recipes/mobile/perps/performance.homepage.ios-cold-disk-cache.recipe.json +122 -0
- package/library/recipes/mobile/perps/performance.homepage.ios-cold-no-cache.recipe.json +95 -0
- package/package.json +1 -1
- package/adapters/mobile/metro-config.cjs +0 -93
|
@@ -2,9 +2,10 @@
|
|
|
2
2
|
# start-metro — ensure the Metro bundler is running for this MetaMask Mobile checkout.
|
|
3
3
|
#
|
|
4
4
|
# Checks if Metro is already running and owned by this checkout with required env vars.
|
|
5
|
-
# Clears stale or foreign listeners before starting. Uses
|
|
6
|
-
# yarn
|
|
7
|
-
#
|
|
5
|
+
# Clears stale or foreign listeners before starting. Uses the checkout's canonical
|
|
6
|
+
# yarn watch script and sets EXPO_NO_TYPESCRIPT_SETUP=1. Cache clearing is
|
|
7
|
+
# injected as Expo --clear without invoking Mobile's global watchman cleanup.
|
|
8
|
+
# Metro owns its default worker count; METRO_MAX_WORKERS is an explicit override.
|
|
8
9
|
#
|
|
9
10
|
# Inputs:
|
|
10
11
|
# --target <metamask-mobile dir> (default $PWD)
|
|
@@ -49,7 +50,7 @@ fi
|
|
|
49
50
|
# only ever inspect or signal a Metro listening on $PORT.
|
|
50
51
|
# shellcheck disable=SC1091
|
|
51
52
|
. "$SCRIPT_DIR/lib/metro-listener.sh"
|
|
52
|
-
MOBILE_METRO_REQUIRED_ENV="${MOBILE_METRO_REQUIRED_ENV:-MM_INFURA_PROJECT_ID
|
|
53
|
+
MOBILE_METRO_REQUIRED_ENV="${MOBILE_METRO_REQUIRED_ENV:-MM_INFURA_PROJECT_ID METAMASK_BUILD_TYPE METAMASK_ENVIRONMENT}"
|
|
53
54
|
|
|
54
55
|
# RECIPE_RUNTIME_DIR (relative, validated) makes runtime state per-run so jobs
|
|
55
56
|
# sharing one checkout do not collide on metro.log / metro.pid / metro.tmux.
|
|
@@ -57,39 +58,90 @@ LOG_DIR="$TARGET/$(recipe_runtime_dir)" || exit 1
|
|
|
57
58
|
mkdir -p "$LOG_DIR"
|
|
58
59
|
LOG_FILE="$LOG_DIR/metro.log"
|
|
59
60
|
PID_FILE="$LOG_DIR/metro.pid"
|
|
60
|
-
|
|
61
|
+
METRO_BUILD_ENV_FILE="$LOG_DIR/metro-build-env.sh"
|
|
61
62
|
|
|
62
63
|
# --- helpers ------------------------------------------------------------------
|
|
63
64
|
|
|
64
|
-
|
|
65
|
-
local
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
65
|
+
write_metro_build_env() {
|
|
66
|
+
local temporary name staged_name product_values build_type environment sentry_dsn clear_value=0
|
|
67
|
+
for name in $MOBILE_METRO_REQUIRED_ENV; do
|
|
68
|
+
if ! printf '%s' "$name" | grep -Eq '^[A-Za-z_][A-Za-z0-9_]*$'; then
|
|
69
|
+
printf 'start-metro: invalid required environment variable name: %s\n' "$name" >&2
|
|
70
|
+
return 1
|
|
71
|
+
fi
|
|
72
|
+
done
|
|
73
|
+
temporary="$(mktemp "$LOG_DIR/.metro-build-env.XXXXXX")" || return 1
|
|
74
|
+
chmod 600 "$temporary" || { rm -f "$temporary"; return 1; }
|
|
75
|
+
product_values="$(
|
|
76
|
+
cd "$TARGET" || exit 1
|
|
77
|
+
set -a
|
|
78
|
+
# shellcheck disable=SC1091
|
|
79
|
+
[ ! -f .js.env ] || eval "$(tr -d '\r' < .js.env)"
|
|
80
|
+
set +a
|
|
81
|
+
METAMASK_BUILD_TYPE="${METAMASK_BUILD_TYPE:-main}"
|
|
82
|
+
METAMASK_ENVIRONMENT="${METAMASK_ENVIRONMENT:-dev}"
|
|
83
|
+
printf 'build_type=%q\n' "$METAMASK_BUILD_TYPE"
|
|
84
|
+
printf 'environment=%q\n' "$METAMASK_ENVIRONMENT"
|
|
85
|
+
printf 'sentry_dsn=%q\n' "${MM_SENTRY_DSN:-${MM_SENTRY_DSN_DEV:-}}"
|
|
86
|
+
for name in $MOBILE_METRO_REQUIRED_ENV; do
|
|
87
|
+
printf 'required_%s=%q\n' "$name" "${!name-}"
|
|
88
|
+
done
|
|
89
|
+
)" || { rm -f "$temporary"; return 1; }
|
|
90
|
+
eval "$product_values"
|
|
91
|
+
[ "$CLEAR" = true ] && clear_value=1
|
|
92
|
+
{
|
|
93
|
+
printf 'export MM_HARNESS_METRO_WATCHER_PORT=%q\n' "$PORT"
|
|
94
|
+
printf 'export MM_HARNESS_METRO_BUILD_TYPE=%q\n' "$build_type"
|
|
95
|
+
printf 'export MM_HARNESS_METRO_ENVIRONMENT=%q\n' "$environment"
|
|
96
|
+
printf 'export MM_HARNESS_METRO_SENTRY_DSN=%q\n' "$sentry_dsn"
|
|
97
|
+
printf 'export MM_HARNESS_METRO_CLEAR=%q\n' "$clear_value"
|
|
98
|
+
cat <<'BUILD_ENV'
|
|
99
|
+
mm_harness_restore_metro_env() {
|
|
100
|
+
export WATCHER_PORT="$MM_HARNESS_METRO_WATCHER_PORT"
|
|
101
|
+
export METRO_PORT="$MM_HARNESS_METRO_WATCHER_PORT"
|
|
102
|
+
export METAMASK_BUILD_TYPE="$MM_HARNESS_METRO_BUILD_TYPE"
|
|
103
|
+
export METAMASK_ENVIRONMENT="$MM_HARNESS_METRO_ENVIRONMENT"
|
|
104
|
+
export MM_SENTRY_DSN="$MM_HARNESS_METRO_SENTRY_DSN"
|
|
105
|
+
BUILD_ENV
|
|
106
|
+
for name in $MOBILE_METRO_REQUIRED_ENV; do
|
|
107
|
+
staged_name="required_$name"
|
|
108
|
+
printf ' export %s=%q\n' "$name" "${!staged_name}"
|
|
109
|
+
done
|
|
110
|
+
for name in \
|
|
111
|
+
SEGMENT_PROXY_URL \
|
|
112
|
+
SEGMENT_WRITE_KEY \
|
|
113
|
+
SEGMENT_FLUSH_INTERVAL \
|
|
114
|
+
SEGMENT_FLUSH_EVENT_LIMIT
|
|
115
|
+
do
|
|
116
|
+
if declare -p "$name" >/dev/null 2>&1; then
|
|
117
|
+
printf ' export %s=%q\n' "$name" "${!name}"
|
|
118
|
+
fi
|
|
119
|
+
done
|
|
120
|
+
cat <<'BUILD_ENV'
|
|
121
|
+
}
|
|
122
|
+
eval() {
|
|
123
|
+
builtin eval "$@"
|
|
124
|
+
local status=$?
|
|
125
|
+
mm_harness_restore_metro_env
|
|
126
|
+
return "$status"
|
|
127
|
+
}
|
|
128
|
+
source() {
|
|
129
|
+
builtin source "$@"
|
|
130
|
+
local status=$?
|
|
131
|
+
mm_harness_restore_metro_env
|
|
132
|
+
return "$status"
|
|
133
|
+
}
|
|
134
|
+
yarn() {
|
|
135
|
+
if [ "$MM_HARNESS_METRO_CLEAR" = 1 ] && [ "${1:-}" = expo ] && [ "${2:-}" = start ]; then
|
|
136
|
+
command yarn "$@" --clear
|
|
72
137
|
else
|
|
73
|
-
|
|
138
|
+
command yarn "$@"
|
|
74
139
|
fi
|
|
75
140
|
}
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
temporary
|
|
80
|
-
chmod 600 "$temporary" || { rm -f "$temporary"; return 1; }
|
|
81
|
-
for name in \
|
|
82
|
-
SEGMENT_PROXY_URL \
|
|
83
|
-
SEGMENT_WRITE_KEY \
|
|
84
|
-
SEGMENT_FLUSH_INTERVAL \
|
|
85
|
-
SEGMENT_FLUSH_EVENT_LIMIT
|
|
86
|
-
do
|
|
87
|
-
if declare -p "$name" >/dev/null 2>&1; then
|
|
88
|
-
printf 'export %s=%q\n' "$name" "${!name}" >> "$temporary" \
|
|
89
|
-
|| { rm -f "$temporary"; return 1; }
|
|
90
|
-
fi
|
|
91
|
-
done
|
|
92
|
-
mv -f "$temporary" "$INLINE_ENV_FILE"
|
|
141
|
+
mm_harness_restore_metro_env
|
|
142
|
+
BUILD_ENV
|
|
143
|
+
} > "$temporary" || { rm -f "$temporary"; return 1; }
|
|
144
|
+
mv -f "$temporary" "$METRO_BUILD_ENV_FILE"
|
|
93
145
|
}
|
|
94
146
|
|
|
95
147
|
# Log-tail viewer window (session-safe): sourced so its run-owned-session rule is
|
|
@@ -99,37 +151,20 @@ write_inline_metro_env() {
|
|
|
99
151
|
|
|
100
152
|
write_metro_runner() {
|
|
101
153
|
local runner="$1"
|
|
102
|
-
local
|
|
103
|
-
|
|
154
|
+
local watcher_script="watch"
|
|
155
|
+
local worker_env_line='unset METRO_MAX_WORKERS'
|
|
156
|
+
if [ -n "$METRO_WORKERS" ]; then
|
|
157
|
+
worker_env_line="export METRO_MAX_WORKERS=$(printf '%q' "$METRO_WORKERS")"
|
|
158
|
+
fi
|
|
104
159
|
cat > "$runner" <<EOF
|
|
105
160
|
#!/usr/bin/env bash
|
|
106
161
|
set -uo pipefail
|
|
107
162
|
cd "$(printf '%q' "$TARGET")"
|
|
108
163
|
: > "$(printf '%q' "$LOG_FILE")"
|
|
109
|
-
set -a
|
|
110
|
-
[ ! -f .js.env ] || . ./.js.env
|
|
111
|
-
[ ! -f .env ] || . ./.env
|
|
112
|
-
[ ! -f .env.local ] || . ./.env.local
|
|
113
|
-
set +a
|
|
114
|
-
[ -n "\${METAMASK_BUILD_TYPE:-}" ] || export METAMASK_BUILD_TYPE=main
|
|
115
|
-
# build.sh remaps MM_SENTRY_DSN from MM_SENTRY_DSN_DEV for dev builds; quick-launch
|
|
116
|
-
# bypasses it, so without this Sentry never initializes in the dev client.
|
|
117
|
-
[ -n "\${MM_SENTRY_DSN:-}" ] || export MM_SENTRY_DSN="\${MM_SENTRY_DSN_DEV:-}"
|
|
118
|
-
[ ! -f $(printf '%q' "$INLINE_ENV_FILE") ] || {
|
|
119
|
-
. $(printf '%q' "$INLINE_ENV_FILE")
|
|
120
|
-
rm -f $(printf '%q' "$INLINE_ENV_FILE")
|
|
121
|
-
}
|
|
122
164
|
export EXPO_NO_TYPESCRIPT_SETUP=1
|
|
123
165
|
export WATCHER_PORT="$(printf '%q' "$PORT")" METRO_PORT="$(printf '%q' "$PORT")"
|
|
124
|
-
|
|
125
|
-
|
|
126
|
-
if [ -n "\${EXPO_OVERRIDE_METRO_CONFIG:-}" ]; then
|
|
127
|
-
export MM_HARNESS_METRO_BASE_CONFIG="\$EXPO_OVERRIDE_METRO_CONFIG"
|
|
128
|
-
else
|
|
129
|
-
export MM_HARNESS_METRO_BASE_CONFIG=$(printf '%q' "$TARGET/metro.config.js")
|
|
130
|
-
fi
|
|
131
|
-
export EXPO_OVERRIDE_METRO_CONFIG="$(printf '%q' "$SCRIPT_DIR/metro-config.cjs")"
|
|
132
|
-
yarn expo start --port "$(printf '%q' "$PORT")"$clear_flag 2>&1 | tee -a "$(printf '%q' "$LOG_FILE")"
|
|
166
|
+
$worker_env_line
|
|
167
|
+
BASH_ENV=$(printf '%q' "$METRO_BUILD_ENV_FILE") command yarn $(printf '%q' "$watcher_script") 2>&1 | tee -a "$(printf '%q' "$LOG_FILE")"
|
|
133
168
|
EOF
|
|
134
169
|
chmod +x "$runner"
|
|
135
170
|
}
|
|
@@ -180,64 +215,61 @@ fi
|
|
|
180
215
|
|
|
181
216
|
stop_metro_listener || exit 1
|
|
182
217
|
|
|
183
|
-
METRO_WORKERS="${METRO_MAX_WORKERS
|
|
218
|
+
METRO_WORKERS="${METRO_MAX_WORKERS:-}"
|
|
219
|
+
if [ -n "$METRO_WORKERS" ] && ! printf '%s' "$METRO_WORKERS" | grep -Eq '^[1-9][0-9]*$'; then
|
|
220
|
+
printf 'start-metro: METRO_MAX_WORKERS must be a positive integer, got %s\n' "$METRO_WORKERS" >&2
|
|
221
|
+
exit 2
|
|
222
|
+
fi
|
|
223
|
+
READY_TIMEOUT="${MOBILE_METRO_READY_TIMEOUT_SECONDS:-180}"
|
|
224
|
+
if ! printf '%s' "$READY_TIMEOUT" | grep -Eq '^[1-9][0-9]*$'; then
|
|
225
|
+
printf 'start-metro: MOBILE_METRO_READY_TIMEOUT_SECONDS must be a positive integer, got %s\n' "$READY_TIMEOUT" >&2
|
|
226
|
+
exit 2
|
|
227
|
+
fi
|
|
184
228
|
CLEAR_LABEL=""
|
|
185
229
|
[ "$CLEAR" = true ] && CLEAR_LABEL=", clear"
|
|
186
230
|
|
|
187
|
-
|
|
188
|
-
|
|
231
|
+
if [ -n "$METRO_WORKERS" ]; then
|
|
232
|
+
printf 'Starting Metro on port %s (workers=%s%s)\n' \
|
|
233
|
+
"$PORT" "$METRO_WORKERS" "$CLEAR_LABEL" >&2
|
|
234
|
+
else
|
|
235
|
+
printf 'Starting Metro on port %s (workers=Metro default%s)\n' \
|
|
236
|
+
"$PORT" "$CLEAR_LABEL" >&2
|
|
237
|
+
fi
|
|
189
238
|
printf '(Full log: %s — mm-harness logs | mm-harness logs --full)\n' "$LOG_FILE" >&2
|
|
190
239
|
|
|
191
|
-
|
|
192
|
-
printf 'start-metro: could not stage
|
|
240
|
+
write_metro_build_env || {
|
|
241
|
+
printf 'start-metro: could not stage the scoped Metro environment\n' >&2
|
|
193
242
|
exit 1
|
|
194
243
|
}
|
|
195
244
|
|
|
196
|
-
|
|
245
|
+
STARTED_METRO_PID=""
|
|
246
|
+
STARTED_METRO_TMUX=""
|
|
247
|
+
if start_metro_tmux; then
|
|
248
|
+
STARTED_METRO_TMUX="$(cat "$LOG_DIR/metro.tmux" 2>/dev/null || true)"
|
|
249
|
+
else
|
|
197
250
|
# Metro runs detached, writing to the log. The tmux window is a read-only tail.
|
|
198
251
|
(
|
|
199
252
|
cd "$TARGET"
|
|
200
253
|
: > "$LOG_FILE"
|
|
201
|
-
# Source product env files (provides MM_INFURA_PROJECT_ID and friends).
|
|
202
|
-
set -a
|
|
203
|
-
# shellcheck disable=SC1091
|
|
204
|
-
[ ! -f .js.env ] || . ./.js.env
|
|
205
|
-
# shellcheck disable=SC1091
|
|
206
|
-
[ ! -f .env ] || . ./.env
|
|
207
|
-
# shellcheck disable=SC1091
|
|
208
|
-
[ ! -f .env.local ] || . ./.env.local
|
|
209
|
-
set +a
|
|
210
|
-
# The quick-launch runs `expo start` directly, bypassing scripts/build.sh which
|
|
211
|
-
# passes the build type as an argument (start:ios = build.sh ios main dev). The
|
|
212
|
-
# fixture .js.env ships METAMASK_BUILD_TYPE="" (empty), which Metro's transform
|
|
213
|
-
# rejects. Default to the main dev client (what runway installs / launch targets)
|
|
214
|
-
# so already-installed slots work without re-syncing fixtures.
|
|
215
|
-
[ -n "${METAMASK_BUILD_TYPE:-}" ] || export METAMASK_BUILD_TYPE=main
|
|
216
|
-
# build.sh remaps MM_SENTRY_DSN from MM_SENTRY_DSN_DEV for dev builds (start:ios
|
|
217
|
-
# = build.sh ios main dev); quick-launch bypasses build.sh, so without this the
|
|
218
|
-
# dev client's MM_SENTRY_DSN stays empty and Sentry never initializes.
|
|
219
|
-
[ -n "${MM_SENTRY_DSN:-}" ] || export MM_SENTRY_DSN="${MM_SENTRY_DSN_DEV:-}"
|
|
220
|
-
[ ! -f "$INLINE_ENV_FILE" ] || {
|
|
221
|
-
# shellcheck disable=SC1090
|
|
222
|
-
. "$INLINE_ENV_FILE"
|
|
223
|
-
rm -f "$INLINE_ENV_FILE"
|
|
224
|
-
}
|
|
225
254
|
export EXPO_NO_TYPESCRIPT_SETUP=1
|
|
226
255
|
export WATCHER_PORT="${PORT}" METRO_PORT="${PORT}"
|
|
227
|
-
|
|
228
|
-
|
|
229
|
-
|
|
230
|
-
|
|
256
|
+
if [ -n "$METRO_WORKERS" ]; then
|
|
257
|
+
export METRO_MAX_WORKERS="${METRO_WORKERS}"
|
|
258
|
+
else
|
|
259
|
+
unset METRO_MAX_WORKERS
|
|
260
|
+
fi
|
|
231
261
|
launcher_args=(
|
|
232
262
|
--target "$TARGET"
|
|
233
263
|
--port "$PORT"
|
|
234
264
|
--log "$LOG_FILE"
|
|
235
265
|
--pid-file "$PID_FILE"
|
|
236
|
-
--
|
|
266
|
+
--build-env "$METRO_BUILD_ENV_FILE"
|
|
237
267
|
)
|
|
268
|
+
[ -z "$METRO_WORKERS" ] || launcher_args+=(--workers "$METRO_WORKERS")
|
|
238
269
|
[ "$CLEAR" = true ] && launcher_args+=(--clear)
|
|
239
270
|
node "$SCRIPT_DIR/launch-metro.cjs" "${launcher_args[@]}"
|
|
240
271
|
) || exit 1
|
|
272
|
+
STARTED_METRO_PID="$(cat "$PID_FILE" 2>/dev/null || true)"
|
|
241
273
|
|
|
242
274
|
start_viewer_window "$LOG_FILE"
|
|
243
275
|
fi
|
|
@@ -256,9 +288,11 @@ if [ -f "$LOG_TUI" ] && command -v node >/dev/null 2>&1 \
|
|
|
256
288
|
TAIL_PID=$!
|
|
257
289
|
fi
|
|
258
290
|
|
|
259
|
-
#
|
|
291
|
+
# Cold main bundles can spend more than 90 seconds initializing Metro before the
|
|
292
|
+
# status listener opens. Keep the wait bounded, but do not trigger a duplicate
|
|
293
|
+
# recovery process while the checkout-owned launch is still making progress.
|
|
260
294
|
READY=false
|
|
261
|
-
for _i in $(seq 1
|
|
295
|
+
for _i in $(seq 1 "$READY_TIMEOUT"); do
|
|
262
296
|
if metro_ready; then
|
|
263
297
|
READY=true
|
|
264
298
|
break
|
|
@@ -277,12 +311,50 @@ if [ "$READY" = true ]; then
|
|
|
277
311
|
exit 0
|
|
278
312
|
fi
|
|
279
313
|
|
|
314
|
+
cleanup_timed_out_metro() {
|
|
315
|
+
local pid pgid i group_alive=false owned_listener_alive=false listener_pid command
|
|
316
|
+
pid="$STARTED_METRO_PID"
|
|
317
|
+
# launch-metro creates a detached group whose PGID equals its initial child
|
|
318
|
+
# PID. The leader can exit before Expo, so probe the group rather than the PID.
|
|
319
|
+
pgid="$pid"
|
|
320
|
+
if [ -n "$pgid" ] && kill -0 -- "-$pgid" 2>/dev/null; then
|
|
321
|
+
printf 'start-metro: stopping timed-out Metro process group %s\n' "$pgid" >&2
|
|
322
|
+
kill -TERM -- "-$pgid" 2>/dev/null || true
|
|
323
|
+
for i in $(seq 1 20); do
|
|
324
|
+
kill -0 -- "-$pgid" 2>/dev/null || break
|
|
325
|
+
sleep 0.25
|
|
326
|
+
done
|
|
327
|
+
if kill -0 -- "-$pgid" 2>/dev/null; then
|
|
328
|
+
kill -KILL -- "-$pgid" 2>/dev/null || true
|
|
329
|
+
for i in $(seq 1 20); do
|
|
330
|
+
kill -0 -- "-$pgid" 2>/dev/null || break
|
|
331
|
+
sleep 0.25
|
|
332
|
+
done
|
|
333
|
+
fi
|
|
334
|
+
kill -0 -- "-$pgid" 2>/dev/null && group_alive=true
|
|
335
|
+
fi
|
|
336
|
+
if [ -n "$STARTED_METRO_TMUX" ] && command -v tmux >/dev/null 2>&1; then
|
|
337
|
+
tmux kill-window -t "$STARTED_METRO_TMUX" >/dev/null 2>&1 || true
|
|
338
|
+
fi
|
|
339
|
+
for listener_pid in $(metro_listener_pids); do
|
|
340
|
+
command="$(ps eww -p "$listener_pid" -o command= 2>/dev/null || true)"
|
|
341
|
+
case "$command" in *"$TARGET"*) owned_listener_alive=true ;; esac
|
|
342
|
+
done
|
|
343
|
+
if [ "$group_alive" = true ] || [ "$owned_listener_alive" = true ]; then
|
|
344
|
+
printf 'start-metro: timed-out checkout-owned process is still alive; retaining launch metadata for recovery\n' >&2
|
|
345
|
+
return 1
|
|
346
|
+
fi
|
|
347
|
+
rm -f "$PID_FILE" "$LOG_DIR/metro-launch.json" "$LOG_DIR/metro-build-env.sh" "$LOG_DIR/metro.tmux"
|
|
348
|
+
return 0
|
|
349
|
+
}
|
|
350
|
+
|
|
280
351
|
# Failure: dump log events for diagnosis.
|
|
281
352
|
if [ -f "$LOG_TUI" ] && command -v node >/dev/null 2>&1; then
|
|
282
353
|
node "$LOG_TUI" events --log "$LOG_FILE" --events 20 >&2 || true
|
|
283
354
|
else
|
|
284
355
|
tail -40 "$LOG_FILE" >&2 || true
|
|
285
356
|
fi
|
|
286
|
-
|
|
357
|
+
cleanup_timed_out_metro || true
|
|
358
|
+
printf 'start-metro: Metro did not become ready on port %s within %ss\n' "$PORT" "$READY_TIMEOUT" >&2
|
|
287
359
|
printf ' Next: check %s for errors, or run mm-harness logs\n' "$LOG_FILE" >&2
|
|
288
360
|
exit 1
|
|
@@ -62,6 +62,7 @@ fi
|
|
|
62
62
|
set +e
|
|
63
63
|
rm -f "$PID_FILE"
|
|
64
64
|
rm -f "$LOG_DIR/metro-launch.json"
|
|
65
|
+
rm -f "$LOG_DIR/metro-build-env.sh"
|
|
65
66
|
|
|
66
67
|
# start-metro runs a per-checkout console-forwarder holding the device debugger
|
|
67
68
|
# slot; Metro going down must take it too, or the orphan keeps polling and later
|
|
@@ -60,6 +60,20 @@ async function prepareMobile(target, opts = {}) {
|
|
|
60
60
|
return { status: EXIT.runtime, output: msg };
|
|
61
61
|
}
|
|
62
62
|
const iosAccessibilityChanged = platform === "ios" && report.decision !== "unknown" && enableIosAccessibility(json);
|
|
63
|
+
if (report.decision === "ready" && clearMetro) {
|
|
64
|
+
const launch = await dispatchActionSequence(
|
|
65
|
+
withAppRestart(
|
|
66
|
+
launchActions(path.resolve(target), true),
|
|
67
|
+
restartApp || iosAccessibilityChanged
|
|
68
|
+
),
|
|
69
|
+
target,
|
|
70
|
+
platform,
|
|
71
|
+
json,
|
|
72
|
+
preflightMode,
|
|
73
|
+
opts.watcherPort
|
|
74
|
+
);
|
|
75
|
+
return launch.status === 0 ? startMobileConsoleForwarder(target, platform, json, opts.watcherPort) : launch;
|
|
76
|
+
}
|
|
63
77
|
if (report.decision === "ready") {
|
|
64
78
|
if (restartApp || iosAccessibilityChanged) {
|
|
65
79
|
const launch2 = await dispatchActionSequence(
|
|
@@ -0,0 +1,284 @@
|
|
|
1
|
+
import { spawn, spawnSync } from "node:child_process";
|
|
2
|
+
import fs from "node:fs";
|
|
3
|
+
import path from "node:path";
|
|
4
|
+
import {
|
|
5
|
+
ANDROID_RECORDING_TARGET_PREFIX,
|
|
6
|
+
IOS_SIMULATOR_RECORDING_TARGET_PREFIX
|
|
7
|
+
} from "../../recording-target.js";
|
|
8
|
+
const RECORDING_START_TIMEOUT_MS = 5e3;
|
|
9
|
+
const RECORDING_STOP_TIMEOUT_MS = 1e4;
|
|
10
|
+
const RECORDING_TIME_LIMIT_SECONDS = 0;
|
|
11
|
+
function androidScreenrecordArgs(remotePath) {
|
|
12
|
+
return [
|
|
13
|
+
"shell",
|
|
14
|
+
"screenrecord",
|
|
15
|
+
"--time-limit",
|
|
16
|
+
String(RECORDING_TIME_LIMIT_SECONDS),
|
|
17
|
+
remotePath
|
|
18
|
+
];
|
|
19
|
+
}
|
|
20
|
+
function createAndroidVideoRecorder(serial) {
|
|
21
|
+
return {
|
|
22
|
+
name: "adb-screenrecord",
|
|
23
|
+
platform: "android",
|
|
24
|
+
doctor: async () => doctorAndroidRecorder(serial),
|
|
25
|
+
start: (request) => startAndroidRecording(serial, request)
|
|
26
|
+
};
|
|
27
|
+
}
|
|
28
|
+
function createIosSimulatorVideoRecorder(simulator) {
|
|
29
|
+
return {
|
|
30
|
+
name: "simctl-recordVideo",
|
|
31
|
+
platform: "ios",
|
|
32
|
+
doctor: async () => doctorIosSimulatorRecorder(simulator),
|
|
33
|
+
start: (request) => startIosSimulatorRecording(simulator, request)
|
|
34
|
+
};
|
|
35
|
+
}
|
|
36
|
+
function doctorIosSimulatorRecorder(simulator) {
|
|
37
|
+
const state = runSimctl(["getenv", simulator, "SIMULATOR_UDID"]);
|
|
38
|
+
if (state.status !== 0 || !state.stdout.trim()) {
|
|
39
|
+
return {
|
|
40
|
+
ok: false,
|
|
41
|
+
code: "ios_simulator_unavailable",
|
|
42
|
+
message: `iOS simulator ${simulator} is not booted: ${commandFailure(state)}`,
|
|
43
|
+
suggestedFix: `Boot ${simulator} and run xcrun simctl getenv ${simulator} SIMULATOR_UDID.`
|
|
44
|
+
};
|
|
45
|
+
}
|
|
46
|
+
return {
|
|
47
|
+
ok: true,
|
|
48
|
+
code: "ok",
|
|
49
|
+
message: `iOS simulator recording is ready on ${simulator}.`
|
|
50
|
+
};
|
|
51
|
+
}
|
|
52
|
+
async function startIosSimulatorRecording(simulator, request) {
|
|
53
|
+
const targetSimulator = iosTargetSimulator(request);
|
|
54
|
+
if (targetSimulator !== simulator) {
|
|
55
|
+
throw new Error(
|
|
56
|
+
`iOS recording target ${targetSimulator ?? "<missing>"} does not match selected simulator ${simulator}.`
|
|
57
|
+
);
|
|
58
|
+
}
|
|
59
|
+
fs.mkdirSync(path.dirname(request.outputPath), { recursive: true });
|
|
60
|
+
const child = spawn(
|
|
61
|
+
"xcrun",
|
|
62
|
+
[
|
|
63
|
+
"simctl",
|
|
64
|
+
"io",
|
|
65
|
+
simulator,
|
|
66
|
+
"recordVideo",
|
|
67
|
+
"--codec=h264",
|
|
68
|
+
"--force",
|
|
69
|
+
request.outputPath
|
|
70
|
+
],
|
|
71
|
+
{ stdio: ["ignore", "pipe", "pipe"] }
|
|
72
|
+
);
|
|
73
|
+
const output = [];
|
|
74
|
+
child.stdout?.setEncoding("utf8");
|
|
75
|
+
child.stderr?.setEncoding("utf8");
|
|
76
|
+
child.stdout?.on("data", (chunk) => output.push(String(chunk)));
|
|
77
|
+
child.stderr?.on("data", (chunk) => output.push(String(chunk)));
|
|
78
|
+
await waitForIosRecordingStart(child, output);
|
|
79
|
+
let stopped = false;
|
|
80
|
+
return {
|
|
81
|
+
async stop() {
|
|
82
|
+
if (stopped) {
|
|
83
|
+
throw new Error(
|
|
84
|
+
`iOS simulator recording on ${simulator} was already stopped.`
|
|
85
|
+
);
|
|
86
|
+
}
|
|
87
|
+
stopped = true;
|
|
88
|
+
child.kill("SIGINT");
|
|
89
|
+
await waitForChildExit(
|
|
90
|
+
child,
|
|
91
|
+
RECORDING_STOP_TIMEOUT_MS,
|
|
92
|
+
"iOS simulator recordVideo"
|
|
93
|
+
);
|
|
94
|
+
const recorded = fs.statSync(request.outputPath);
|
|
95
|
+
if (!recorded.isFile() || recorded.size <= 0) {
|
|
96
|
+
throw new Error(
|
|
97
|
+
`iOS simulator recording output is empty: ${request.outputPath}`
|
|
98
|
+
);
|
|
99
|
+
}
|
|
100
|
+
return {
|
|
101
|
+
recorder: {
|
|
102
|
+
name: "simctl-recordVideo",
|
|
103
|
+
platform: "ios",
|
|
104
|
+
target: { selector: "simulator", value: simulator }
|
|
105
|
+
}
|
|
106
|
+
};
|
|
107
|
+
}
|
|
108
|
+
};
|
|
109
|
+
}
|
|
110
|
+
function doctorAndroidRecorder(serial) {
|
|
111
|
+
const state = runAdb(serial, ["get-state"]);
|
|
112
|
+
if (state.status !== 0 || state.stdout.trim() !== "device") {
|
|
113
|
+
return {
|
|
114
|
+
ok: false,
|
|
115
|
+
code: "android_device_unavailable",
|
|
116
|
+
message: `Android device ${serial} is not ready: ${commandFailure(state)}`,
|
|
117
|
+
suggestedFix: `Run adb -s ${serial} get-state and reconnect or unlock the device.`
|
|
118
|
+
};
|
|
119
|
+
}
|
|
120
|
+
const screenrecord = runAdb(serial, ["shell", "screenrecord", "--help"]);
|
|
121
|
+
if (screenrecord.status !== 0) {
|
|
122
|
+
return {
|
|
123
|
+
ok: false,
|
|
124
|
+
code: "android_screenrecord_unavailable",
|
|
125
|
+
message: `Android screenrecord is unavailable on ${serial}: ${commandFailure(screenrecord)}`,
|
|
126
|
+
suggestedFix: `Run adb -s ${serial} shell screenrecord --help.`
|
|
127
|
+
};
|
|
128
|
+
}
|
|
129
|
+
return {
|
|
130
|
+
ok: true,
|
|
131
|
+
code: "ok",
|
|
132
|
+
message: `Android screen recording is ready on ${serial}.`
|
|
133
|
+
};
|
|
134
|
+
}
|
|
135
|
+
async function startAndroidRecording(serial, request) {
|
|
136
|
+
const targetSerial = androidTargetSerial(request);
|
|
137
|
+
if (targetSerial !== serial) {
|
|
138
|
+
throw new Error(
|
|
139
|
+
`Android recording target ${targetSerial ?? "<missing>"} does not match selected device ${serial}.`
|
|
140
|
+
);
|
|
141
|
+
}
|
|
142
|
+
const existingPid = screenrecordPid(serial);
|
|
143
|
+
if (existingPid) {
|
|
144
|
+
throw new Error(
|
|
145
|
+
`Android screenrecord is already running on ${serial} (pid ${existingPid}). Stop that recording before retrying.`
|
|
146
|
+
);
|
|
147
|
+
}
|
|
148
|
+
const remotePath = `/sdcard/Download/mm-harness-recipe-${Date.now()}-${process.pid}.mp4`;
|
|
149
|
+
const child = spawn(
|
|
150
|
+
"adb",
|
|
151
|
+
["-s", serial, ...androidScreenrecordArgs(remotePath)],
|
|
152
|
+
{ stdio: ["ignore", "pipe", "pipe"] }
|
|
153
|
+
);
|
|
154
|
+
const stderr = [];
|
|
155
|
+
child.stderr?.setEncoding("utf8");
|
|
156
|
+
child.stderr?.on("data", (chunk) => stderr.push(String(chunk)));
|
|
157
|
+
child.stdout?.resume();
|
|
158
|
+
const recorderPid = await waitForScreenrecordStart(serial, child, stderr);
|
|
159
|
+
let stopped = false;
|
|
160
|
+
return {
|
|
161
|
+
async stop() {
|
|
162
|
+
if (stopped) {
|
|
163
|
+
throw new Error(`Android screen recording on ${serial} was already stopped.`);
|
|
164
|
+
}
|
|
165
|
+
stopped = true;
|
|
166
|
+
try {
|
|
167
|
+
stopRemoteScreenrecord(serial, recorderPid);
|
|
168
|
+
await waitForChildExit(
|
|
169
|
+
child,
|
|
170
|
+
RECORDING_STOP_TIMEOUT_MS,
|
|
171
|
+
"Android screenrecord"
|
|
172
|
+
);
|
|
173
|
+
fs.mkdirSync(path.dirname(request.outputPath), { recursive: true });
|
|
174
|
+
const pull = runAdb(serial, ["pull", remotePath, request.outputPath]);
|
|
175
|
+
if (pull.status !== 0) {
|
|
176
|
+
throw new Error(
|
|
177
|
+
`Could not pull Android recording from ${serial}: ${commandFailure(pull)}`
|
|
178
|
+
);
|
|
179
|
+
}
|
|
180
|
+
const output = fs.statSync(request.outputPath);
|
|
181
|
+
if (!output.isFile() || output.size <= 0) {
|
|
182
|
+
throw new Error(`Android recording output is empty: ${request.outputPath}`);
|
|
183
|
+
}
|
|
184
|
+
return {
|
|
185
|
+
recorder: {
|
|
186
|
+
name: "adb-screenrecord",
|
|
187
|
+
platform: "android",
|
|
188
|
+
target: { selector: "adb-serial", value: serial }
|
|
189
|
+
}
|
|
190
|
+
};
|
|
191
|
+
} finally {
|
|
192
|
+
runAdb(serial, ["shell", "rm", "-f", remotePath]);
|
|
193
|
+
}
|
|
194
|
+
}
|
|
195
|
+
};
|
|
196
|
+
}
|
|
197
|
+
function androidTargetSerial(request) {
|
|
198
|
+
if (request.target.kind !== "window-id") return void 0;
|
|
199
|
+
if (!request.target.windowId.startsWith(ANDROID_RECORDING_TARGET_PREFIX)) return void 0;
|
|
200
|
+
return request.target.windowId.slice(ANDROID_RECORDING_TARGET_PREFIX.length) || void 0;
|
|
201
|
+
}
|
|
202
|
+
async function waitForScreenrecordStart(serial, child, stderr) {
|
|
203
|
+
const deadline = Date.now() + RECORDING_START_TIMEOUT_MS;
|
|
204
|
+
while (Date.now() < deadline) {
|
|
205
|
+
if (child.exitCode !== null) {
|
|
206
|
+
throw new Error(
|
|
207
|
+
`Android screenrecord exited before capture started (exit ${child.exitCode}): ${stderr.join("").trim() || "no recorder output"}`
|
|
208
|
+
);
|
|
209
|
+
}
|
|
210
|
+
const pid = screenrecordPid(serial);
|
|
211
|
+
if (pid) return pid;
|
|
212
|
+
await sleep(100);
|
|
213
|
+
}
|
|
214
|
+
child.kill("SIGTERM");
|
|
215
|
+
throw new Error(
|
|
216
|
+
`Android screenrecord did not start on ${serial} within ${RECORDING_START_TIMEOUT_MS}ms.`
|
|
217
|
+
);
|
|
218
|
+
}
|
|
219
|
+
function stopRemoteScreenrecord(serial, pid) {
|
|
220
|
+
const stop = runAdb(serial, ["shell", "kill", "-2", pid]);
|
|
221
|
+
if (stop.status !== 0 && screenrecordPid(serial) === pid) {
|
|
222
|
+
throw new Error(`Could not stop Android screenrecord pid ${pid}: ${commandFailure(stop)}`);
|
|
223
|
+
}
|
|
224
|
+
}
|
|
225
|
+
async function waitForChildExit(child, timeoutMs, recorderName) {
|
|
226
|
+
if (child.exitCode !== null) return;
|
|
227
|
+
await new Promise((resolve, reject) => {
|
|
228
|
+
const timeout = setTimeout(() => {
|
|
229
|
+
child.kill("SIGTERM");
|
|
230
|
+
reject(new Error(`${recorderName} did not stop within ${timeoutMs}ms.`));
|
|
231
|
+
}, timeoutMs);
|
|
232
|
+
child.once("exit", () => {
|
|
233
|
+
clearTimeout(timeout);
|
|
234
|
+
resolve();
|
|
235
|
+
});
|
|
236
|
+
});
|
|
237
|
+
}
|
|
238
|
+
async function waitForIosRecordingStart(child, output) {
|
|
239
|
+
const deadline = Date.now() + RECORDING_START_TIMEOUT_MS;
|
|
240
|
+
while (Date.now() < deadline) {
|
|
241
|
+
if (child.exitCode !== null) {
|
|
242
|
+
throw new Error(
|
|
243
|
+
`iOS simulator recordVideo exited before capture started (exit ${child.exitCode}): ${output.join("").trim() || "no recorder output"}`
|
|
244
|
+
);
|
|
245
|
+
}
|
|
246
|
+
if (output.join("").includes("Recording started")) return;
|
|
247
|
+
await sleep(100);
|
|
248
|
+
}
|
|
249
|
+
if (child.exitCode === null) return;
|
|
250
|
+
throw new Error(
|
|
251
|
+
`iOS simulator recordVideo did not start within ${RECORDING_START_TIMEOUT_MS}ms.`
|
|
252
|
+
);
|
|
253
|
+
}
|
|
254
|
+
function iosTargetSimulator(request) {
|
|
255
|
+
if (request.target.kind !== "window-id") return void 0;
|
|
256
|
+
if (!request.target.windowId.startsWith(
|
|
257
|
+
IOS_SIMULATOR_RECORDING_TARGET_PREFIX
|
|
258
|
+
)) {
|
|
259
|
+
return void 0;
|
|
260
|
+
}
|
|
261
|
+
return request.target.windowId.slice(IOS_SIMULATOR_RECORDING_TARGET_PREFIX.length) || void 0;
|
|
262
|
+
}
|
|
263
|
+
function screenrecordPid(serial) {
|
|
264
|
+
const result = runAdb(serial, ["shell", "pidof", "screenrecord"]);
|
|
265
|
+
if (result.status !== 0) return void 0;
|
|
266
|
+
return result.stdout.trim().split(/\s+/u).find(Boolean);
|
|
267
|
+
}
|
|
268
|
+
function runAdb(serial, args) {
|
|
269
|
+
return spawnSync("adb", ["-s", serial, ...args], { encoding: "utf8" });
|
|
270
|
+
}
|
|
271
|
+
function runSimctl(args) {
|
|
272
|
+
return spawnSync("xcrun", ["simctl", ...args], { encoding: "utf8" });
|
|
273
|
+
}
|
|
274
|
+
function commandFailure(result) {
|
|
275
|
+
return result.stderr.trim() || result.stdout.trim() || `exit ${result.status ?? "unknown"}`;
|
|
276
|
+
}
|
|
277
|
+
function sleep(durationMs) {
|
|
278
|
+
return new Promise((resolve) => setTimeout(resolve, durationMs));
|
|
279
|
+
}
|
|
280
|
+
export {
|
|
281
|
+
androidScreenrecordArgs,
|
|
282
|
+
createAndroidVideoRecorder,
|
|
283
|
+
createIosSimulatorVideoRecorder
|
|
284
|
+
};
|