@deeeed/metamask-harness 0.67.0 → 0.68.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/CHANGELOG.md CHANGED
@@ -2,6 +2,27 @@
2
2
 
3
3
  ## Unreleased
4
4
 
5
+ ## 0.68.0 - 2026-09-29
6
+
7
+ ### Changed
8
+
9
+ - Verify Mobile readiness with one combined status/bridge recipe, retaining device-discovery retries and RPC checks; recognize Lite screens when the release hides the Pro toggle.
10
+ - Restart Metro when build environment inputs change and select the matching transform-cache generation without clearing caches.
11
+ - Forward child checklist progress through the public CLI while retaining the owning step's diff-validation gate.
12
+ - Keep operation and checkout ownership records readable under restrictive umasks, and exclude observation updates from product provenance.
13
+ - Retain command-specific logs and stage observations in `status --watch`; preserve nested operation and checkout ownership across long builds. Pin protocol 0.33.0, recipe-harness 0.21.1, agent-runtime 0.15.1 and expo-recipe 0.12.2 for a consistent runtime dependency tree.
14
+
15
+ ## 0.67.1 - 2026-09-29
16
+
17
+ ### Fixed
18
+
19
+ - Prepare the selected Android display on cold and ready-path launches. Locked or unreadable states return `MOBILE_DEVICE_LOCKED` or `MOBILE_DEVICE_UI_UNAVAILABLE` without recovery; the internal `--ui-only` mode supports Android.
20
+ - Honor the full Mobile bridge startup deadline with integer probe timeouts and aligned fixture-reset bounds, instead of restarting slow cold starts prematurely. Unresponsive runtimes still fail at the caller's deadline.
21
+
22
+ ### Removed
23
+
24
+ - The undocumented `MOBILE_BRIDGE_AGENTIC_GRACE_MS` and `MOBILE_BRIDGE_UNRESPONSIVE_GRACE_MS` early-abort settings; use `MOBILE_BRIDGE_READY_TIMEOUT_MS` to set the startup budget.
25
+
5
26
  ## 0.67.0 - 2026-09-28
6
27
 
7
28
  ### Fixed
@@ -84,8 +84,12 @@ module.exports = async function harnessMetroConfig(baseConfig) {
84
84
  `^${escapeRegExp(tempRoot)}(?:${escapeRegExp(path.sep)}|$)`,
85
85
  blockListFlags(existingBlockList),
86
86
  );
87
+ // Inline environment values affect transformed JavaScript. Keep separate
88
+ // cache generations for those inputs instead of deleting the shared cache.
89
+ const envFingerprint = process.env.MM_HARNESS_METRO_ENV_FINGERPRINT;
87
90
  return {
88
91
  ...productConfig,
92
+ ...(envFingerprint ? { cacheVersion: `${productConfig.cacheVersion ?? ''}:mm-env:${envFingerprint}` } : {}),
89
93
  resolver: {
90
94
  ...productConfig.resolver,
91
95
  blockList: appendBlockList(existingBlockList, tempPattern),
@@ -14,7 +14,7 @@
14
14
  # --adb-serial <serial> Android device serial (default: ADB_SERIAL env)
15
15
  # --preflight-mode fast|auto|rebuild-native|clean (default: fast; env MOBILE_PREFLIGHT_MODE)
16
16
  # --restart terminate the selected app before opening it
17
- # --ui-only iOS only: show the assigned simulator window without relaunching the app
17
+ # --ui-only prepare the selected device UI without launching the app
18
18
  # MM_HARNESS_SIMULATOR_APP absolute Simulator.app or DeviceHub.app path; GUI only
19
19
  # MM_HARNESS_SHOW_SIMULATOR=0 opt out of automatic GUI opening
20
20
  # MM_HARNESS_FOCUS_SIMULATOR=1 explicitly open/activate the GUI, even with SHOW_SIMULATOR=0
@@ -69,10 +69,6 @@ if [ "$PLATFORM" = "android" ]; then
69
69
  fi
70
70
  export MM_HARNESS_ADB_PATH="$ADB_BIN"
71
71
  fi
72
- if $UI_ONLY && [ "$PLATFORM" != "ios" ]; then
73
- printf 'open-device: --ui-only is supported only for ios.\n' >&2
74
- exit 2
75
- fi
76
72
 
77
73
  TARGET="$(cd "$TARGET" && pwd)"
78
74
 
@@ -345,6 +341,52 @@ focus_simulator() {
345
341
 
346
342
  # --- Android helpers ----------------------------------------------------------
347
343
 
344
+ prepare_android_display() {
345
+ local power_state="" wake_state="" trust_state="" lock_state="" _attempt
346
+ printf 'Waking selected Android device before launch\n' >&2
347
+ if ! "$ADB_BIN" "${ADB_ARGS[@]}" shell input keyevent KEYCODE_WAKEUP >&2; then
348
+ printf 'open-device: Android display preparation failed.\nNext: wake and unlock the selected device, then retry.\n' >&2
349
+ return 1
350
+ fi
351
+ for _attempt in {1..12}; do
352
+ if ! power_state="$("$ADB_BIN" "${ADB_ARGS[@]}" shell dumpsys power)"; then
353
+ break
354
+ fi
355
+ wake_state="$(printf '%s\n' "$power_state" | tr -d '\r' | sed -nE 's/^[[:space:]]*mWakefulness=([^[:space:]]+).*/\1/p' | head -1)"
356
+ case "$wake_state" in
357
+ Awake|1) break ;;
358
+ Asleep|Dozing|Dreaming|0|2|3) sleep 0.25 ;;
359
+ *) break ;;
360
+ esac
361
+ done
362
+ if [ "$wake_state" != Awake ] && [ "$wake_state" != 1 ]; then
363
+ printf 'open-device: Android display preparation failed.\nNext: wake the selected device and check adb access, then retry.\n' >&2
364
+ return 1
365
+ fi
366
+ if ! "$ADB_BIN" "${ADB_ARGS[@]}" shell wm dismiss-keyguard >&2; then
367
+ printf 'open-device: Android display preparation failed.\nNext: unlock the selected device, then retry.\n' >&2
368
+ return 1
369
+ fi
370
+ # OS dismissal can finish asynchronously, and cannot unlock a secure keyguard.
371
+ for _attempt in {1..12}; do
372
+ if ! trust_state="$("$ADB_BIN" "${ADB_ARGS[@]}" shell dumpsys trust)"; then
373
+ printf 'open-device: Android lock state unavailable.\nNext: check adb access and the selected device screen, then retry.\n' >&2
374
+ return 1
375
+ fi
376
+ lock_state="$(printf '%s\n' "$trust_state" | tr -d '\r' | sed -nE '/\(current\):/s/.*deviceLocked=([01]|true|false)(,.*)?$/\1/p')"
377
+ case "$lock_state" in
378
+ 0|false) return 0 ;;
379
+ 1|true) sleep 0.25 ;;
380
+ *)
381
+ printf 'open-device: Android lock state unavailable.\nNext: check adb access and the selected device screen, then retry.\n' >&2
382
+ return 1
383
+ ;;
384
+ esac
385
+ done
386
+ printf 'open-device: Android device is locked.\nNext: unlock the selected device, then retry; credentials are never entered automatically.\n' >&2
387
+ return 1
388
+ }
389
+
348
390
  android_app_installed() {
349
391
  local pkg="$1"
350
392
  shift
@@ -597,6 +639,10 @@ if [ "$PLATFORM" = "ios" ]; then
597
639
  else
598
640
  ADB_ARGS=()
599
641
  [ -n "$ADB_SERIAL_ARG" ] && ADB_ARGS+=("-s" "$ADB_SERIAL_ARG")
642
+ if $UI_ONLY; then
643
+ prepare_android_display
644
+ exit $?
645
+ fi
600
646
 
601
647
  LAUNCHED=false
602
648
  for PKG in "${ANDROID_PKGS[@]}"; do
@@ -621,6 +667,8 @@ else
621
667
  || printf 'http%%3A%%2F%%2Flocalhost%%3A%s%%3FdisableOnboarding%%3D1' "$PORT")"
622
668
  DEEP_LINK="expo-metamask://expo-development-client/?url=${ENCODED_URL}"
623
669
 
670
+ prepare_android_display || exit 1
671
+
624
672
  printf 'Launching Android dev client %s\n' "$PKG" >&2
625
673
  if "$ADB_BIN" "${ADB_ARGS[@]}" shell am start \
626
674
  -a android.intent.action.VIEW \
@@ -10,6 +10,7 @@
10
10
  # Inputs:
11
11
  # --target <metamask-mobile dir> (default $PWD)
12
12
  # --port <number> (default: WATCHER_PORT env, else METRO_PORT, else 8081)
13
+ # --restart restart Metro while retaining its transform cache
13
14
  # --clear reset Metro bundler cache before starting
14
15
  # Outputs:
15
16
  # Progress on stderr; exit 0 Metro ready; 1 failed to start; 2 bad args.
@@ -20,16 +21,19 @@ set -uo pipefail
20
21
  TARGET="$PWD"
21
22
  PORT="${WATCHER_PORT:-${METRO_PORT:-8081}}"
22
23
  CLEAR=false
24
+ RESTART=false
23
25
 
24
26
  while [ "$#" -gt 0 ]; do
25
27
  case "$1" in
26
28
  --target) TARGET="$2"; shift 2 ;;
27
29
  --port) PORT="$2"; shift 2 ;;
28
30
  --clear) CLEAR=true; shift ;;
31
+ --restart) RESTART=true; shift ;;
29
32
  -h|--help)
30
- printf 'Usage: start-metro.sh [--target <dir>] [--port <port>] [--clear]\n'
33
+ printf 'Usage: start-metro.sh [--target <dir>] [--port <port>] [--restart] [--clear]\n'
31
34
  printf ' --target MetaMask Mobile checkout directory\n'
32
35
  printf ' --port Metro port (default: WATCHER_PORT env or 8081)\n'
36
+ printf ' --restart restart while preserving the transform cache\n'
33
37
  printf ' --clear restart with cleared cache\n'
34
38
  exit 0
35
39
  ;;
@@ -60,6 +64,9 @@ export MM_HARNESS_METRO_PROJECT_ROOT="$TARGET"
60
64
  export MM_HARNESS_METRO_BASE_CONFIG="$PRODUCT_METRO_CONFIG"
61
65
  export EXPO_OVERRIDE_METRO_CONFIG="$HARNESS_METRO_CONFIG"
62
66
  MOBILE_METRO_REQUIRED_ENV="${MOBILE_METRO_REQUIRED_ENV:-MM_INFURA_PROJECT_ID METAMASK_BUILD_TYPE METAMASK_ENVIRONMENT} MM_HARNESS_METRO_PROJECT_ROOT MM_HARNESS_METRO_BASE_CONFIG EXPO_OVERRIDE_METRO_CONFIG"
67
+ if [ -n "${MM_HARNESS_METRO_ENV_FINGERPRINT:-}" ]; then
68
+ MOBILE_METRO_REQUIRED_ENV="$MOBILE_METRO_REQUIRED_ENV MM_HARNESS_METRO_ENV_FINGERPRINT"
69
+ fi
63
70
 
64
71
  # RECIPE_RUNTIME_DIR (relative, validated) makes runtime state per-run so jobs
65
72
  # sharing one checkout do not collide on metro.log / metro.pid / metro.tmux.
@@ -212,8 +219,12 @@ fi
212
219
 
213
220
  # Decide whether to (re)start or skip.
214
221
  if metro_ready; then
215
- if [ "$CLEAR" = true ]; then
216
- printf 'Restarting Metro on port %s with cleared cache\n' "$PORT" >&2
222
+ if [ "$CLEAR" = true ] || [ "$RESTART" = true ]; then
223
+ if [ "$CLEAR" = true ]; then
224
+ printf 'Restarting Metro on port %s with cleared cache\n' "$PORT" >&2
225
+ else
226
+ printf 'Restarting Metro on port %s with its transform cache preserved\n' "$PORT" >&2
227
+ fi
217
228
  stop_metro_listener || exit 1
218
229
  rm -f "$PID_FILE"
219
230
  elif metro_listener_valid; then
@@ -22,8 +22,6 @@ const {
22
22
  const DEFAULT_MAX_POLLS = 90;
23
23
  const DEFAULT_POLL_INTERVAL_MS = 2_000;
24
24
  const DEFAULT_PROBE_TIMEOUT_MS = 1_500;
25
- const DEFAULT_AGENTIC_GRACE_MS = 15_000;
26
- const DEFAULT_UNRESPONSIVE_GRACE_MS = 12_000;
27
25
  const PROGRESS_INTERVAL_MS = 10_000;
28
26
  const MAX_HTTP_BODY_BYTES = 2 * 1024 * 1024;
29
27
 
@@ -76,15 +74,6 @@ function parseArgs(argv, env = process.env) {
76
74
  env.MOBILE_BRIDGE_PROBE_TIMEOUT_MS || String(DEFAULT_PROBE_TIMEOUT_MS),
77
75
  'MOBILE_BRIDGE_PROBE_TIMEOUT_MS',
78
76
  );
79
- const agenticGraceMs = parsePositiveInteger(
80
- env.MOBILE_BRIDGE_AGENTIC_GRACE_MS || String(DEFAULT_AGENTIC_GRACE_MS),
81
- 'MOBILE_BRIDGE_AGENTIC_GRACE_MS',
82
- );
83
- const unresponsiveGraceMs = parsePositiveInteger(
84
- env.MOBILE_BRIDGE_UNRESPONSIVE_GRACE_MS ||
85
- String(DEFAULT_UNRESPONSIVE_GRACE_MS),
86
- 'MOBILE_BRIDGE_UNRESPONSIVE_GRACE_MS',
87
- );
88
77
 
89
78
  return {
90
79
  ...options,
@@ -93,8 +82,6 @@ function parseArgs(argv, env = process.env) {
93
82
  timeoutMs,
94
83
  pollIntervalMs,
95
84
  probeTimeoutMs,
96
- agenticGraceMs,
97
- unresponsiveGraceMs,
98
85
  };
99
86
  }
100
87
 
@@ -128,15 +115,17 @@ function fetchJson(port, pathname, timeoutMs) {
128
115
  }
129
116
 
130
117
  function runBridgeStatus({ bridgePath, target, timeoutMs, env }) {
118
+ // performance.now() leaves fractional milliseconds near the deadline.
119
+ const budgetMs = Math.max(1, Math.floor(timeoutMs));
131
120
  const result = spawnSync(process.execPath, [bridgePath, 'status'], {
132
121
  cwd: target,
133
122
  env: {
134
123
  ...env,
135
124
  APP_ROOT: target,
136
- CDP_TIMEOUT: String(timeoutMs),
125
+ CDP_TIMEOUT: String(budgetMs),
137
126
  },
138
127
  encoding: 'utf8',
139
- timeout: timeoutMs,
128
+ timeout: budgetMs,
140
129
  killSignal: 'SIGTERM',
141
130
  maxBuffer: MAX_HTTP_BODY_BYTES,
142
131
  });
@@ -154,7 +143,7 @@ function runBridgeStatus({ bridgePath, target, timeoutMs, env }) {
154
143
  stdout || stderr
155
144
  ? `${stdout}${stderr}`.trimEnd()
156
145
  : result.error?.code === 'ETIMEDOUT'
157
- ? `bridge status probe exceeded ${timeoutMs}ms`
146
+ ? `bridge status probe exceeded ${budgetMs}ms`
158
147
  : '',
159
148
  timedOut: result.error?.code === 'ETIMEDOUT',
160
149
  };
@@ -270,26 +259,15 @@ function waitReason(observation, state, config) {
270
259
  if (observation.metro === 'no-target') {
271
260
  return 'Metro running; no React Native debug target yet';
272
261
  }
273
- if (state.agenticMissingSince !== null) {
262
+ if (state.agenticMissing) {
274
263
  return 'React Native target answered; in-app bridge not installed yet';
275
264
  }
276
- if (state.unresponsiveSince !== null) {
265
+ if (state.unresponsive) {
277
266
  return 'React Native target visible; CDP is not answering';
278
267
  }
279
268
  return 'React Native target answered; waiting for an in-app route';
280
269
  }
281
270
 
282
- function failure(kind, startedAt, deadline, now, attempts, lastObservation) {
283
- return {
284
- ok: false,
285
- kind,
286
- attempts,
287
- elapsedMs: Math.max(0, now - startedAt),
288
- remainingMs: Math.max(0, deadline - now),
289
- lastObservation,
290
- };
291
- }
292
-
293
271
  async function waitForBridge(config, dependencies = {}) {
294
272
  const now = dependencies.now || (() => performance.now());
295
273
  const sleep =
@@ -299,11 +277,13 @@ async function waitForBridge(config, dependencies = {}) {
299
277
  const progress = dependencies.progress || (() => undefined);
300
278
  const status = dependencies.status || (() => undefined);
301
279
  const startedAt = now();
280
+ // Native inspector discovery can precede JavaScript and bridge installation.
281
+ // Only the caller's startup deadline bounds those intermediate states.
302
282
  const deadline = startedAt + config.timeoutMs;
303
283
  const state = {
304
284
  readyStreak: 0,
305
- unresponsiveSince: null,
306
- agenticMissingSince: null,
285
+ unresponsive: false,
286
+ agenticMissing: false,
307
287
  };
308
288
  let attempts = 0;
309
289
  let nextProgressAt = startedAt;
@@ -328,8 +308,8 @@ async function waitForBridge(config, dependencies = {}) {
328
308
 
329
309
  if (hasMatchingRoute(lastObservation.statusValue, config.env)) {
330
310
  state.readyStreak += 1;
331
- state.unresponsiveSince = null;
332
- state.agenticMissingSince = null;
311
+ state.unresponsive = false;
312
+ state.agenticMissing = false;
333
313
  if (state.readyStreak >= 2) {
334
314
  return {
335
315
  ok: true,
@@ -345,37 +325,13 @@ async function waitForBridge(config, dependencies = {}) {
345
325
  (snapshot) => snapshot.agenticPresent === false,
346
326
  );
347
327
  if (agenticMissing) {
348
- state.agenticMissingSince ??= afterProbe;
349
- state.unresponsiveSince = null;
350
- if (afterProbe - state.agenticMissingSince >= config.agenticGraceMs) {
351
- return failure(
352
- 'agentic-missing',
353
- startedAt,
354
- deadline,
355
- afterProbe,
356
- attempts,
357
- lastObservation,
358
- );
359
- }
328
+ state.agenticMissing = true;
329
+ state.unresponsive = false;
360
330
  } else {
361
- state.agenticMissingSince = null;
331
+ state.agenticMissing = false;
362
332
  const targetUnresponsive =
363
333
  lastObservation.metro === 'target-visible' && snapshots.length === 0;
364
- if (targetUnresponsive) state.unresponsiveSince ??= afterProbe;
365
- else state.unresponsiveSince = null;
366
- if (
367
- state.unresponsiveSince !== null &&
368
- afterProbe - state.unresponsiveSince >= config.unresponsiveGraceMs
369
- ) {
370
- return failure(
371
- 'target-unresponsive',
372
- startedAt,
373
- deadline,
374
- afterProbe,
375
- attempts,
376
- lastObservation,
377
- );
378
- }
334
+ state.unresponsive = targetUnresponsive;
379
335
  }
380
336
  }
381
337
 
@@ -394,14 +350,15 @@ async function waitForBridge(config, dependencies = {}) {
394
350
  }
395
351
 
396
352
  const endedAt = now();
397
- return failure(
398
- 'deadline',
399
- startedAt,
400
- deadline,
401
- endedAt,
353
+ return {
354
+ ok: false,
355
+ kind: 'deadline',
402
356
  attempts,
357
+ elapsedMs: Math.max(0, endedAt - startedAt),
358
+ remainingMs: Math.max(0, deadline - endedAt),
359
+ reason: waitReason(lastObservation, state, config),
403
360
  lastObservation,
404
- );
361
+ };
405
362
  }
406
363
 
407
364
  function bundleProgress(logPath) {
@@ -507,21 +464,11 @@ async function main() {
507
464
  process.stderr.write(fs.readFileSync(statusLog, 'utf8'));
508
465
  } catch {}
509
466
  const requested = describeRequested(env);
510
- if (result.kind === 'target-unresponsive') {
511
- process.stderr.write(
512
- `wait-for-bridge: React Native target for ${requested} was visible but CDP did not answer for ${formatSeconds(parsed.unresponsiveGraceMs)} (${formatSeconds(result.elapsedMs)} elapsed, ${formatSeconds(result.remainingMs)} remaining).\n`,
513
- );
514
- } else if (result.kind === 'agentic-missing') {
515
- process.stderr.write(
516
- `wait-for-bridge: React Native target for ${requested} answered without __AGENTIC__ for ${formatSeconds(parsed.agenticGraceMs)}.\n`,
517
- );
518
- } else {
519
- process.stderr.write(
520
- `wait-for-bridge: readiness deadline expired after ${formatSeconds(result.elapsedMs)} on port ${parsed.port}.\n`,
521
- );
522
- }
523
467
  process.stderr.write(
524
- `wait-for-bridge: no bridge target matched the requested ${requested} on port ${parsed.port}.\n`,
468
+ `wait-for-bridge: readiness deadline expired after ${formatSeconds(result.elapsedMs)} on port ${parsed.port}: ${result.reason}.\n`,
469
+ );
470
+ process.stderr.write(
471
+ `wait-for-bridge: no bridge target matched the requested ${requested} on port ${parsed.port}: ${result.reason}.\n`,
525
472
  );
526
473
  process.stderr.write(` requested: ${requested}\n`);
527
474
  process.stderr.write(
@@ -242,7 +242,6 @@ function startMobileConsoleForwarder(target, platform, json, watcherPort) {
242
242
  );
243
243
  }
244
244
  function ensureMobileHumanSurface(target, platform, json, watcherPort) {
245
- if (platform !== "ios") return Promise.resolve({ status: 0, output: "" });
246
245
  return dispatchAction(
247
246
  { id: "ensure-device-ui", cwd: target },
248
247
  target,
@@ -271,7 +270,8 @@ async function dispatchActionSequence(requestedActions, target, platform, json,
271
270
  platform,
272
271
  json,
273
272
  preflightMode,
274
- watcherPort
273
+ watcherPort,
274
+ pendingMetroEnvFingerprint
275
275
  );
276
276
  if (result.status !== 0) return result;
277
277
  if (action.id === "yarn-setup") recordDepsBaseline(resolved);
@@ -379,7 +379,7 @@ function enableIosAccessibility(json) {
379
379
  return false;
380
380
  }
381
381
  }
382
- async function dispatchAction(action, target, platform, json, preflightMode = "fast", watcherPort) {
382
+ async function dispatchAction(action, target, platform, json, preflightMode = "fast", watcherPort, metroEnvFingerprint) {
383
383
  const cwd = action.cwd ?? target;
384
384
  switch (action.id) {
385
385
  case "yarn-setup": {
@@ -397,7 +397,8 @@ async function dispatchAction(action, target, platform, json, preflightMode = "f
397
397
  return spawnScriptStreaming(
398
398
  leaf,
399
399
  withWatcherPort(["--target", cwd, ...extra], watcherPort),
400
- target
400
+ target,
401
+ { MM_HARNESS_METRO_ENV_FINGERPRINT: metroEnvFingerprint ?? mobileMetroEnvCheck(path.resolve(target)).fingerprint }
401
402
  );
402
403
  }
403
404
  case "start-console-forwarder": {
@@ -184,7 +184,9 @@ async function decideMobileReadiness(target, options = {}) {
184
184
  env.status === "missing" ? "Metro environment has no successful bundle baseline; restart Metro to load the current environment without clearing its transform cache." : "Metro environment inputs changed since the successful bundle baseline; restart Metro without clearing its transform cache. Pass --clear-metro only if stale transforms are observed.",
185
185
  ...report.reasons
186
186
  ],
187
- actions: report.decision === "ready" ? launchActions(resolved) : report.actions
187
+ actions: (report.decision === "ready" ? launchActions(resolved) : report.actions).map(
188
+ (action) => action.id === "start-metro" && !action.argv?.includes("--clear") && !action.argv?.includes("--restart") ? { ...action, argv: [...action.argv ?? [], "--restart"] } : action
189
+ )
188
190
  };
189
191
  }
190
192
  function writeDepsNote(resolved) {
@@ -1,7 +1,8 @@
1
- import { execFileSync, spawnSync } from "node:child_process";
1
+ import { execFileSync } from "node:child_process";
2
2
  import crypto from "node:crypto";
3
3
  import fs from "node:fs";
4
4
  import path from "node:path";
5
+ import { spawnScriptStreaming } from "../../commands/shared.js";
5
6
  import { readRuntimeContextField } from "../../harness.js";
6
7
  import { recipeHarnessPath, recipeHarnessRoot, recipeRuntimeDir, runnerDir } from "../../paths.js";
7
8
  const USAGE = "Usage: mm-harness verify --adapter mobile -- [--target <metamask-mobile>] [--artifacts-dir <dir>] [--static-only] [--platform ios|android] [--preflight-mode fast|auto|default|rebuild-native|clean] [--no-auto-start]";
@@ -11,19 +12,6 @@ const AUTO_START_TRUE = ["1", "true", "TRUE", "True", "yes", "YES", "Yes", "on",
11
12
  const AUTO_START_FALSE = ["0", "false", "FALSE", "False", "no", "NO", "No", "off", "OFF", "Off", ""];
12
13
  const AUTO_START_REFUSAL = "Mobile auto-start is not allowed from product-local scripts. Start or prepare the app through the runner/slot runtime, then rerun verify with --no-auto-start.";
13
14
  const EVM_READINESS_EXPRESSION = "(async function(){try{var engine=globalThis.Engine;var controller=engine&&engine.context&&engine.context.NetworkController;if(!controller||typeof controller.getSelectedNetworkClient!=='function')throw new Error('NetworkController selected client is unavailable');var client=controller.getSelectedNetworkClient();if(!client||!client.provider||typeof client.provider.request!=='function')throw new Error('selected EVM provider is unavailable');var result=await client.provider.request({method:'eth_blockNumber'});if(typeof result!=='string'||!/^0x[0-9a-f]+$/i.test(result))throw new Error('eth_blockNumber returned an invalid result');return {ok:true,result:result};}catch(error){return {ok:false,error:error&&(error.message||String(error))};}})()";
14
- const STATUS_SMOKE_RECIPE = `{
15
- "$schema": "https://farmslot.io/schemas/recipe-v1.schema.json",
16
- "title": "Mobile runner status smoke",
17
- "description": "Checks that the installed runner can read Mobile app status before live verification.",
18
- "workflow": {
19
- "entry": "status",
20
- "nodes": {
21
- "status": { "action": "app.status", "intent": "Read Mobile app status through the runner-owned bridge", "next": "done" },
22
- "done": { "action": "end", "status": "pass" }
23
- }
24
- }
25
- }
26
- `;
27
15
  const LIVE_SMOKE_RECIPE = `{
28
16
  "$schema": "https://farmslot.io/schemas/recipe-v1.schema.json",
29
17
  "title": "Mobile v1 runner live bridge smoke",
@@ -241,30 +229,21 @@ function mobilePortHolder(port) {
241
229
  note: "HTTP /status probing is skipped during live verify because the React Native bridge is the authoritative readiness path for Mobile."
242
230
  };
243
231
  }
244
- function spawnToLog(logPath, command, args, options = {}) {
232
+ async function spawnToLog(logPath, command, args, options = {}) {
245
233
  fs.mkdirSync(path.dirname(logPath), { recursive: true });
246
- const fd = fs.openSync(logPath, "w");
247
- try {
248
- const result = spawnSync(command, args, {
249
- cwd: options.cwd,
250
- env: options.env ?? process.env,
251
- stdio: ["ignore", fd, fd],
252
- ...options.timeoutMs === void 0 ? {} : { timeout: options.timeoutMs }
253
- });
254
- if (result.error) {
255
- const timedOut = result.error.code === "ETIMEDOUT";
256
- fs.writeSync(
257
- fd,
258
- timedOut ? `Timed out after ${(options.timeoutMs ?? 0) / 1e3}s: ${[command, ...args].join(" ")}
259
- ` : `${result.error.message}
260
- `
261
- );
262
- return timedOut ? 124 : 127;
263
- }
264
- return result.status ?? 1;
265
- } finally {
266
- fs.closeSync(fd);
234
+ const result = await spawnScriptStreaming(command, args, options.cwd ?? process.cwd(), {
235
+ env: Object.fromEntries(Object.entries(options.env ?? process.env).filter((entry) => entry[1] !== void 0)),
236
+ timeoutMs: options.timeoutMs,
237
+ forward: "none",
238
+ stage: path.basename(logPath, ".log")
239
+ });
240
+ fs.writeFileSync(logPath, result.output);
241
+ if (result.error) {
242
+ fs.appendFileSync(logPath, `${result.error.message}
243
+ `);
244
+ return 127;
267
245
  }
246
+ return result.timedOut ? 124 : result.status;
268
247
  }
269
248
  function readJsonFile(file) {
270
249
  try {
@@ -372,42 +351,6 @@ async function runMobileVerify(argv, io = defaultIo) {
372
351
  deviceTarget = platform === "android" ? resolveJsEnvValue(target, "ADB_SERIAL") || resolveJsEnvValue(target, "ANDROID_DEVICE") : resolveJsEnvValue(target, "IOS_SIMULATOR");
373
352
  }
374
353
  const deviceArgs = deviceTarget ? ["--device", deviceTarget] : [];
375
- const statusSmokeRecipe = path.join(artifacts, "mobile-status-smoke.recipe.json");
376
- const precheckLog = path.join(artifacts, "logs/app-status-precheck.log");
377
- let liveRuntimeOk = false;
378
- for (let attempt = 1; attempt <= 5; attempt += 1) {
379
- fs.writeFileSync(statusSmokeRecipe, STATUS_SMOKE_RECIPE);
380
- const code = spawnToLog(precheckLog, runnerBin, [
381
- "run",
382
- statusSmokeRecipe,
383
- "--adapter",
384
- "mobile",
385
- "--target",
386
- target,
387
- ...deviceArgs,
388
- "--watcher-port",
389
- port,
390
- "--artifacts-dir",
391
- path.join(artifacts, "runner-status-smoke"),
392
- "--json"
393
- ], { cwd: target });
394
- if (code === 0) {
395
- liveRuntimeOk = true;
396
- break;
397
- }
398
- const deviceNotFound = /"code"[ \t]*:[ \t]*"DEVICE_NOT_FOUND"/u.test(
399
- fs.readFileSync(precheckLog, "utf8")
400
- );
401
- if (attempt < 5 && deviceNotFound) {
402
- io.err(`Waiting for Mobile device discovery (${attempt}/5): ${deviceTarget || "configured target"}`);
403
- await sleep(1e3);
404
- continue;
405
- }
406
- break;
407
- }
408
- if (!liveRuntimeOk && autoStart) io.err(AUTO_START_REFUSAL);
409
- checks.push(liveRuntimeOk ? { name: "mobile runtime controllable precheck", status: "pass" } : { name: "mobile runtime controllable precheck", status: "fail", detail: "see logs/app-status-precheck.log" });
410
- if (!liveRuntimeOk) status = "fail";
411
354
  fixtureStatus = mobileFixtureStatus(target);
412
355
  fs.writeFileSync(path.join(artifacts, "logs/fixture-status.json"), `${JSON.stringify(fixtureStatus)}
413
356
  `);
@@ -423,20 +366,19 @@ async function runMobileVerify(argv, io = defaultIo) {
423
366
  portHolder = mobilePortHolder(port);
424
367
  fs.writeFileSync(path.join(artifacts, "logs/port-holder.json"), `${JSON.stringify(portHolder)}
425
368
  `);
426
- if (status !== "fail" && portHolder.listening) {
369
+ if (!checks.some((check) => check.status === "fail") && portHolder.listening) {
427
370
  const waiter = path.join(runnerDir, "adapters/mobile/wait-for-bridge.sh");
428
371
  if (!fs.existsSync(waiter)) {
429
372
  addNote("Mobile bridge settle skipped: wait-for-bridge.sh not found under the runner adapters; reinstall the runner.");
430
373
  } else {
431
- const waiterCode = spawnToLog(
374
+ const waiterCode = await spawnToLog(
432
375
  path.join(artifacts, "logs/wait-for-bridge.log"),
433
376
  "bash",
434
377
  [waiter, "--target", target, "--port", port, "--platform", platform],
435
378
  {
436
379
  env: {
437
380
  ...process.env,
438
- MOBILE_BRIDGE_READY_TIMEOUT_MS: process.env.MOBILE_BRIDGE_READY_TIMEOUT_MS || "90000",
439
- MOBILE_BRIDGE_UNRESPONSIVE_GRACE_MS: process.env.MOBILE_BRIDGE_UNRESPONSIVE_GRACE_MS || "90000"
381
+ MOBILE_BRIDGE_READY_TIMEOUT_MS: process.env.MOBILE_BRIDGE_READY_TIMEOUT_MS || "90000"
440
382
  }
441
383
  }
442
384
  );
@@ -447,34 +389,45 @@ async function runMobileVerify(argv, io = defaultIo) {
447
389
  fs.writeFileSync(liveSmokeRecipe, LIVE_SMOKE_RECIPE);
448
390
  const iosSimulatorResolved = resolveJsEnvValue(target, "IOS_SIMULATOR");
449
391
  const adbSerialResolved = resolveJsEnvValue(target, "ADB_SERIAL");
450
- const liveSmokeCode = spawnToLog(
451
- path.join(artifacts, "logs/runner-live-smoke.log"),
452
- runnerBin,
453
- [
454
- "run",
455
- liveSmokeRecipe,
456
- "--adapter",
457
- "mobile",
458
- "--target",
459
- target,
460
- ...deviceArgs,
461
- "--watcher-port",
462
- port,
463
- "--artifacts-dir",
464
- path.join(artifacts, "runner-live-smoke"),
465
- "--json"
466
- ],
467
- {
468
- cwd: target,
469
- env: {
470
- ...process.env,
471
- METAMASK_RECIPE_AUTO_HUD: "0",
472
- IOS_SIMULATOR: process.env.IOS_SIMULATOR || iosSimulatorResolved,
473
- ADB_SERIAL: process.env.ADB_SERIAL || adbSerialResolved,
474
- ANDROID_SERIAL: process.env.ANDROID_SERIAL || adbSerialResolved
392
+ let liveSmokeCode = 1;
393
+ for (let attempt = 1; attempt <= 5; attempt += 1) {
394
+ liveSmokeCode = await spawnToLog(
395
+ path.join(artifacts, "logs/runner-live-smoke.log"),
396
+ runnerBin,
397
+ [
398
+ "run",
399
+ liveSmokeRecipe,
400
+ "--adapter",
401
+ "mobile",
402
+ "--target",
403
+ target,
404
+ ...deviceArgs,
405
+ "--watcher-port",
406
+ port,
407
+ "--artifacts-dir",
408
+ path.join(artifacts, "runner-live-smoke"),
409
+ "--json"
410
+ ],
411
+ {
412
+ cwd: target,
413
+ env: {
414
+ ...process.env,
415
+ METAMASK_RECIPE_AUTO_HUD: "0",
416
+ IOS_SIMULATOR: process.env.IOS_SIMULATOR || iosSimulatorResolved,
417
+ ADB_SERIAL: process.env.ADB_SERIAL || adbSerialResolved,
418
+ ANDROID_SERIAL: process.env.ANDROID_SERIAL || adbSerialResolved
419
+ }
475
420
  }
476
- }
477
- );
421
+ );
422
+ if (liveSmokeCode === 0) break;
423
+ const deviceNotFound = /"code"[ \t]*:[ \t]*"DEVICE_NOT_FOUND"/u.test(
424
+ fs.readFileSync(path.join(artifacts, "logs/runner-live-smoke.log"), "utf8")
425
+ );
426
+ if (!deviceNotFound || attempt === 5) break;
427
+ io.err(`Waiting for Mobile device discovery (${attempt}/5): ${deviceTarget || "configured target"}`);
428
+ await sleep(1e3);
429
+ }
430
+ if (liveSmokeCode !== 0 && autoStart) io.err(AUTO_START_REFUSAL);
478
431
  if (liveSmokeCode === 0) {
479
432
  checks.push({ name: "runner v1 live bridge smoke", status: "pass" });
480
433
  } else {
@@ -489,7 +442,7 @@ async function runMobileVerify(argv, io = defaultIo) {
489
442
  fs.writeFileSync(evmLog, `Mobile bridge is missing at ${bridge}
490
443
  `);
491
444
  } else {
492
- const evmCode = spawnToLog(evmLog, process.execPath, [bridge, "eval-async", EVM_READINESS_EXPRESSION], {
445
+ const evmCode = await spawnToLog(evmLog, process.execPath, [bridge, "eval-async", EVM_READINESS_EXPRESSION], {
493
446
  timeoutMs: 25e3,
494
447
  env: {
495
448
  ...process.env,