@deeeed/metamask-harness 0.34.0 → 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.
@@ -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 yarn expo start --port (not
6
- # yarn start), sources .js.env/.env/.env.local, sets EXPO_NO_TYPESCRIPT_SETUP=1, and
7
- # caps METRO_MAX_WORKERS to 6 (or the physical CPU count).
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} MM_HARNESS_METRO_PROJECT_ROOT EXPO_OVERRIDE_METRO_CONFIG"
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
- INLINE_ENV_FILE="$LOG_DIR/metro-inline-env.sh"
61
+ METRO_BUILD_ENV_FILE="$LOG_DIR/metro-build-env.sh"
61
62
 
62
63
  # --- helpers ------------------------------------------------------------------
63
64
 
64
- default_metro_workers() {
65
- local cores=""
66
- if command -v sysctl >/dev/null 2>&1; then
67
- cores="$(sysctl -n hw.perflevel0.physicalcpu 2>/dev/null || sysctl -n hw.physicalcpu 2>/dev/null || true)"
68
- fi
69
- if printf '%s' "${cores:-}" | grep -Eq '^[0-9]+$' && [ "${cores:-0}" -gt 0 ]; then
70
- [ "${cores:-4}" -gt 6 ] && cores=6
71
- printf '%s\n' "$cores"
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
- printf '4\n'
138
+ command yarn "$@"
74
139
  fi
75
140
  }
76
-
77
- write_inline_metro_env() {
78
- local temporary name
79
- temporary="$(mktemp "$LOG_DIR/.metro-inline-env.XXXXXX")" || return 1
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 clear_flag=""
103
- [ "$CLEAR" = true ] && clear_flag=' --clear'
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
- export METRO_MAX_WORKERS="$(printf '%q' "$METRO_WORKERS")"
125
- export MM_HARNESS_METRO_PROJECT_ROOT="$(printf '%q' "$TARGET")"
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:-$(default_metro_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
- printf 'Starting Metro on port %s (workers=%s%s)\n' \
188
- "$PORT" "$METRO_WORKERS" "$CLEAR_LABEL" >&2
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
- write_inline_metro_env || {
192
- printf 'start-metro: could not stage inline Metro environment\n' >&2
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
- if ! start_metro_tmux; then
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
- export METRO_MAX_WORKERS="${METRO_WORKERS}"
228
- export MM_HARNESS_METRO_PROJECT_ROOT="$TARGET"
229
- export MM_HARNESS_METRO_BASE_CONFIG="${EXPO_OVERRIDE_METRO_CONFIG:-$TARGET/metro.config.js}"
230
- export EXPO_OVERRIDE_METRO_CONFIG="$SCRIPT_DIR/metro-config.cjs"
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
- --workers "$METRO_WORKERS"
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
- # Ready-poll: 90 × 1s = 90 s timeout.
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 90); do
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
- printf 'start-metro: Metro did not become ready on port %s within 90s\n' "$PORT" >&2
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
@@ -7,7 +7,16 @@ import {
7
7
  } from "../../recording-target.js";
8
8
  const RECORDING_START_TIMEOUT_MS = 5e3;
9
9
  const RECORDING_STOP_TIMEOUT_MS = 1e4;
10
- const RECORDING_TIME_LIMIT_SECONDS = 180;
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
+ }
11
20
  function createAndroidVideoRecorder(serial) {
12
21
  return {
13
22
  name: "adb-screenrecord",
@@ -139,15 +148,7 @@ async function startAndroidRecording(serial, request) {
139
148
  const remotePath = `/sdcard/Download/mm-harness-recipe-${Date.now()}-${process.pid}.mp4`;
140
149
  const child = spawn(
141
150
  "adb",
142
- [
143
- "-s",
144
- serial,
145
- "shell",
146
- "screenrecord",
147
- "--time-limit",
148
- String(RECORDING_TIME_LIMIT_SECONDS),
149
- remotePath
150
- ],
151
+ ["-s", serial, ...androidScreenrecordArgs(remotePath)],
151
152
  { stdio: ["ignore", "pipe", "pipe"] }
152
153
  );
153
154
  const stderr = [];
@@ -277,6 +278,7 @@ function sleep(durationMs) {
277
278
  return new Promise((resolve) => setTimeout(resolve, durationMs));
278
279
  }
279
280
  export {
281
+ androidScreenrecordArgs,
280
282
  createAndroidVideoRecorder,
281
283
  createIosSimulatorVideoRecorder
282
284
  };
@@ -316,6 +316,15 @@ async function handleLaunchLocked(argv, stream) {
316
316
  originalError: attempt.output.trim() || void 0
317
317
  });
318
318
  }
319
+ if (adapter === "mobile" && tier === "build" && /open-device: (?:Android|iOS) native build failed/u.test(attempt.output)) {
320
+ return launchFail(jsonOutput, stream, adapter, mobileTarget, tier, state, target, {
321
+ code: "MOBILE_NATIVE_BUILD_FAILED",
322
+ message: "the selected Mobile native build failed; refusing to repeat the expensive build as a Metro recovery.",
323
+ recoverable: false,
324
+ exitCode: EXIT.runtime,
325
+ originalError: attempt.output.trim() || void 0
326
+ });
327
+ }
319
328
  const bound = checkHealBounds(target, attempt.output, state);
320
329
  if (bound !== null) {
321
330
  return launchFail(jsonOutput, stream, adapter, mobileTarget, tier, state, target, {
@@ -4,7 +4,7 @@ async function launchMobile(target, mobileTarget, tier, json, restartApp = false
4
4
  await ensureHarnessFresh(target, "mobile");
5
5
  const platform = mobileTarget ?? "ios";
6
6
  const watcherPort = process.env.WATCHER_PORT ? parseInt(process.env.WATCHER_PORT, 10) : void 0;
7
- const preflightMode = tier === "build" ? "auto" : "fast";
7
+ const preflightMode = tier === "build" ? "rebuild-native" : "fast";
8
8
  return prepareMobile(target, {
9
9
  platform,
10
10
  json,
@@ -356,6 +356,13 @@ function liveAdapterProcessTimeoutMs(node) {
356
356
  const timeoutMs = Number(node.timeout_ms);
357
357
  return Number.isFinite(timeoutMs) && timeoutMs > 0 ? timeoutMs + settleAllowance + 5e3 : 6e4;
358
358
  }
359
+ const actionTimeouts = [node.target_timeout_ms, node.unlock_timeout_ms].map(Number).filter((value) => Number.isFinite(value) && value > 0);
360
+ if (actionTimeouts.length > 0) {
361
+ return Math.max(
362
+ 6e4,
363
+ actionTimeouts.reduce((total, value) => total + value, 0) + 5e3
364
+ );
365
+ }
359
366
  return 6e4 + settleAllowance;
360
367
  }
361
368
  function commandForPrepared(projectRoot, platform) {
@@ -205,12 +205,65 @@ function socketPipelineMeasurements(records) {
205
205
  });
206
206
  }
207
207
 
208
+ function firstFreshDeliveryChecks(records, requirements) {
209
+ return requirements.map((requirement) => {
210
+ const stream = String(requirement.stream);
211
+ const requiredLifecycle =
212
+ requirement.lifecycle === undefined
213
+ ? null
214
+ : String(requirement.lifecycle);
215
+ const subscriberThrottleMs = Number(requirement.subscriber_throttle_ms);
216
+ const maxSocketToSubscriberMs = Number(
217
+ requirement.max_socket_to_subscriber_ms,
218
+ );
219
+ const socket = records.find(
220
+ (record) =>
221
+ record.stage === 'socket_received' &&
222
+ record.source === 'fresh_socket' &&
223
+ record.stream === stream &&
224
+ (requiredLifecycle === null ||
225
+ record.lifecycle === requiredLifecycle) &&
226
+ record.delivery_id,
227
+ );
228
+ const delivery = socket
229
+ ? records.find(
230
+ (record) =>
231
+ record.stage === 'subscriber_delivery' &&
232
+ record.delivery_id === socket.delivery_id &&
233
+ record.stream === stream &&
234
+ record.lifecycle === socket.lifecycle &&
235
+ Number(record.throttle_ms ?? 0) === subscriberThrottleMs,
236
+ )
237
+ : null;
238
+ const socketAt = Number(socket?.monotonic_ms);
239
+ const subscriberAt = Number(delivery?.monotonic_ms);
240
+ const socketToSubscriberMs =
241
+ Number.isFinite(socketAt) && Number.isFinite(subscriberAt)
242
+ ? roundMs(subscriberAt - socketAt)
243
+ : null;
244
+ return {
245
+ stream,
246
+ lifecycle: socket?.lifecycle ?? requiredLifecycle,
247
+ subscriberThrottleMs,
248
+ maxSocketToSubscriberMs,
249
+ deliveryId: socket?.delivery_id ?? null,
250
+ socketToSubscriberMs,
251
+ status:
252
+ socketToSubscriberMs !== null &&
253
+ socketToSubscriberMs <= maxSocketToSubscriberMs
254
+ ? 'pass'
255
+ : 'fail',
256
+ };
257
+ });
258
+ }
259
+
208
260
  function summarize(
209
261
  records,
210
262
  sourcePath,
211
263
  offset,
212
264
  bytesScanned,
213
265
  requiredFreshContentVariants = [],
266
+ firstFreshDeliveryRequirements = [],
214
267
  ) {
215
268
  const stages = {};
216
269
  const lifecycles = {};
@@ -240,6 +293,10 @@ function summarize(
240
293
  lastMonotonicMs: monotonicValues.length > 0 ? Math.max(...monotonicValues) : null,
241
294
  visibleMeasurements: visibleMeasurements(records, requiredFreshContentVariants),
242
295
  socketPipelineMeasurements: socketPipelineMeasurements(records),
296
+ firstFreshDeliveryChecks: firstFreshDeliveryChecks(
297
+ records,
298
+ firstFreshDeliveryRequirements,
299
+ ),
243
300
  };
244
301
  }
245
302
 
@@ -317,6 +374,18 @@ export async function captureHomepagePerformance(input) {
317
374
  contentVariantSatisfies(actual, variant),
318
375
  ),
319
376
  );
377
+ const firstFreshDeliveryRequirements = Array.isArray(
378
+ node.first_fresh_delivery_requirements,
379
+ )
380
+ ? node.first_fresh_delivery_requirements
381
+ : [];
382
+ const deliveryChecks = firstFreshDeliveryChecks(
383
+ records,
384
+ firstFreshDeliveryRequirements,
385
+ );
386
+ const failedFirstFreshDeliveries = deliveryChecks.filter(
387
+ (check) => check.status === 'fail',
388
+ );
320
389
 
321
390
  const prefix = String(node.artifact_prefix ?? 'logs/homepage-performance');
322
391
  const logPath = `${prefix}.log`;
@@ -329,7 +398,8 @@ export async function captureHomepagePerformance(input) {
329
398
  missingStages.length === 0 &&
330
399
  missingLifecycles.length === 0 &&
331
400
  missingSources.length === 0 &&
332
- missingFreshContentVariants.length === 0
401
+ missingFreshContentVariants.length === 0 &&
402
+ failedFirstFreshDeliveries.length === 0
333
403
  ? 'pass'
334
404
  : 'fail',
335
405
  requireRecords,
@@ -337,6 +407,7 @@ export async function captureHomepagePerformance(input) {
337
407
  missingLifecycles,
338
408
  missingSources,
339
409
  missingFreshContentVariants,
410
+ failedFirstFreshDeliveries,
340
411
  };
341
412
  const summary = {
342
413
  ...summarize(
@@ -345,6 +416,7 @@ export async function captureHomepagePerformance(input) {
345
416
  offset,
346
417
  content.length - offset,
347
418
  requiredFreshContentVariants,
419
+ firstFreshDeliveryRequirements,
348
420
  ),
349
421
  validation,
350
422
  };
@@ -371,6 +443,16 @@ export async function captureHomepagePerformance(input) {
371
443
  `Missing required fresh ${MARKER} content variants: ${missingFreshContentVariants.join(', ')}.`,
372
444
  );
373
445
  }
446
+ if (failedFirstFreshDeliveries.length > 0) {
447
+ const failures = failedFirstFreshDeliveries.map(
448
+ (check) =>
449
+ `${check.stream}/${check.lifecycle ?? 'any'} throttle=${check.subscriberThrottleMs}ms ` +
450
+ `measured=${check.socketToSubscriberMs ?? 'missing'}ms max=${check.maxSocketToSubscriberMs}ms`,
451
+ );
452
+ throw new Error(
453
+ `First fresh ${MARKER} delivery requirements failed: ${failures.join('; ')}.`,
454
+ );
455
+ }
374
456
 
375
457
  return {
376
458
  phase,
@@ -23,6 +23,41 @@ export const MOBILE_BRIDGE_ERROR_CODES = BRIDGE_ERROR_CODES;
23
23
 
24
24
  const execFileAsync = promisify(execFile);
25
25
 
26
+ let adapterActionActive = false;
27
+ let actionBridgeLockPath = null;
28
+
29
+ function resolveBridgeLockPath(input, env) {
30
+ const configuredRuntime = env.RECIPE_RUNTIME_DIR;
31
+ const runtime = configuredRuntime
32
+ ? (path.isAbsolute(configuredRuntime)
33
+ ? configuredRuntime
34
+ : path.resolve(input.context.projectRoot, configuredRuntime))
35
+ : path.join(input.context.projectRoot, 'temp', 'recipe', 'runtime');
36
+ return path.join(runtime, 'cdp-bridge.lock');
37
+ }
38
+
39
+ async function acquireActionBridgeLock(input, env) {
40
+ if (actionBridgeLockPath) return String(process.pid);
41
+ const lockPath = resolveBridgeLockPath(input, env);
42
+ await mkdir(path.dirname(lockPath), { recursive: true });
43
+ await writeFile(lockPath, String(process.pid));
44
+ actionBridgeLockPath = lockPath;
45
+ return String(process.pid);
46
+ }
47
+
48
+ async function releaseActionBridgeLock() {
49
+ const lockPath = actionBridgeLockPath;
50
+ actionBridgeLockPath = null;
51
+ if (!lockPath) return;
52
+ try {
53
+ if ((await readFile(lockPath, 'utf8')).trim() === String(process.pid)) {
54
+ await rm(lockPath, { force: true });
55
+ }
56
+ } catch {
57
+ // The bridge child or a newer owner may already have replaced the lock.
58
+ }
59
+ }
60
+
26
61
  // Recover the typed code a cdp-bridge child reported: the stderr marker is
27
62
  // authoritative (it carries the class the bridge classified at the source), the
28
63
  // coded exit status is the fallback for output-less failures, and a raw message
@@ -52,8 +87,14 @@ export async function writeOutput(input, output) {
52
87
 
53
88
  export async function runAdapter(callback) {
54
89
  const input = await loadInput();
55
- const output = await callback(input);
56
- await writeOutput(input, output);
90
+ adapterActionActive = true;
91
+ try {
92
+ const output = await callback(input);
93
+ await writeOutput(input, output);
94
+ } finally {
95
+ adapterActionActive = false;
96
+ await releaseActionBridgeLock();
97
+ }
57
98
  }
58
99
 
59
100
  function bridgeScript(input) {
@@ -127,7 +168,13 @@ export async function bridgeEnv(input) {
127
168
  // (e.g. "Pixel 6" matches Metro deviceName "Pixel 6 - 16 - API 36").
128
169
  const serialStr = adbSerial != null ? String(adbSerial) : '';
129
170
  const deviceStr = androidDevice != null ? String(androidDevice) : '';
130
- if (serialStr && deviceStr === serialStr) {
171
+ if (
172
+ shouldResolveAndroidTargetModel(
173
+ serialStr,
174
+ deviceStr,
175
+ androidTargetDeviceName,
176
+ )
177
+ ) {
131
178
  const model = await resolveAndroidModel(serialStr);
132
179
  if (model) {
133
180
  env.ANDROID_TARGET_DEVICE_NAME = model;
@@ -150,6 +197,17 @@ export async function bridgeEnv(input) {
150
197
  return env;
151
198
  }
152
199
 
200
+ export function shouldResolveAndroidTargetModel(
201
+ adbSerial,
202
+ androidDevice,
203
+ androidTargetDeviceName,
204
+ ) {
205
+ const serial = String(adbSerial ?? '').trim();
206
+ const device = String(androidDevice ?? '').trim();
207
+ const targetName = String(androidTargetDeviceName ?? '').trim();
208
+ return Boolean(serial && !targetName && (!device || device === serial));
209
+ }
210
+
153
211
  function resolveMobileTarget(input) {
154
212
  const contextEnv = input.context?.env || {};
155
213
  const watcherPort = input.node?.watcher_port ?? input.node?.metro_port ?? input.node?.cdp_port ?? contextEnv.WATCHER_PORT ?? contextEnv.CDP_PORT ?? contextEnv.RECIPE_CDP_PORT ?? process.env.WATCHER_PORT ?? process.env.CDP_PORT ?? process.env.RECIPE_CDP_PORT;
@@ -314,6 +372,9 @@ export async function bridgeCommand(input, args) {
314
372
  const script = bridgeScript(input);
315
373
  // bridgeEnv is async: it may call `adb getprop` to resolve the Metro device name.
316
374
  const env = await bridgeEnv(input);
375
+ if (adapterActionActive) {
376
+ env.CDP_BRIDGE_LOCK_OWNER_PID = await acquireActionBridgeLock(input, env);
377
+ }
317
378
  const result = await new Promise((resolve, reject) => {
318
379
  const timeoutMs = Number(input.node?.bridge_timeout_ms ?? input.node?.cdp_timeout_ms ?? process.env.CDP_TIMEOUT ?? 30000);
319
380
  const child = spawn(process.execPath, [script, ...args], {