@deeeed/metamask-harness 0.67.0 → 0.67.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 CHANGED
@@ -2,6 +2,17 @@
2
2
 
3
3
  ## Unreleased
4
4
 
5
+ ## 0.67.1 - 2026-09-29
6
+
7
+ ### Fixed
8
+
9
+ - 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.
10
+ - 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.
11
+
12
+ ### Removed
13
+
14
+ - 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.
15
+
5
16
  ## 0.67.0 - 2026-09-28
6
17
 
7
18
  ### Fixed
@@ -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 \
@@ -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,
@@ -435,8 +435,7 @@ async function runMobileVerify(argv, io = defaultIo) {
435
435
  {
436
436
  env: {
437
437
  ...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"
438
+ MOBILE_BRIDGE_READY_TIMEOUT_MS: process.env.MOBILE_BRIDGE_READY_TIMEOUT_MS || "90000"
440
439
  }
441
440
  }
442
441
  );
@@ -12,6 +12,7 @@ import { FeatureFlagSpecError, fixtureFeatureFlags, parseRemoteFlagSpec, sameFea
12
12
  import { readExtensionPinnedFlags } from "../adapters/extension/feature-flags.js";
13
13
  const FIXTURES_BOOLEANS = /* @__PURE__ */ new Set(["dev", "force", "json"]);
14
14
  const FIXTURE_STREAM_TIMEOUT_MS = 12e4;
15
+ const FIXTURE_BRIDGE_READY_TIMEOUT_MS = 18e4;
15
16
  const MOBILE_SETUP_WALLET_TIMEOUT_MS = 36e4;
16
17
  async function handleFixtures(argv) {
17
18
  const { positional, options } = parseFlags(argv, FIXTURES_BOOLEANS);
@@ -251,9 +252,9 @@ async function handleFixturesLocked(argv) {
251
252
  const waitForBridge = path.join(runnerDir, "adapters/mobile/wait-for-bridge.sh");
252
253
  const waitResult = await spawnScriptStreaming(
253
254
  waitForBridge,
254
- ["--target", target, "--port", port, "--platform", platform],
255
+ ["--target", target, "--port", port, "--platform", platform, "--timeout-ms", String(FIXTURE_BRIDGE_READY_TIMEOUT_MS)],
255
256
  target,
256
- { timeoutMs: FIXTURE_STREAM_TIMEOUT_MS }
257
+ { timeoutMs: FIXTURE_BRIDGE_READY_TIMEOUT_MS + 1e4 }
257
258
  );
258
259
  if (waitResult.status !== 0) {
259
260
  resetError = new Error("Mobile bridge did not return after app data reset.");
@@ -381,6 +381,26 @@ async function handleLaunchLocked(argv, stream) {
381
381
  originalError: attempt.output.trim() || void 0
382
382
  });
383
383
  }
384
+ if (adapter === "mobile" && attempt.output.includes("open-device: Android device is locked.")) {
385
+ return launchFail(jsonOutput, stream, adapter, mobileTarget, tier, state, target, {
386
+ code: "MOBILE_DEVICE_LOCKED",
387
+ message: "the selected Android device is locked.",
388
+ recoverable: false,
389
+ userAction: "Unlock the selected Android device, then retry the launch.",
390
+ exitCode: EXIT.runtime,
391
+ originalError: attempt.output.trim()
392
+ });
393
+ }
394
+ if (adapter === "mobile" && /open-device: Android (?:lock state unavailable|display preparation failed)\./u.test(attempt.output)) {
395
+ return launchFail(jsonOutput, stream, adapter, mobileTarget, tier, state, target, {
396
+ code: "MOBILE_DEVICE_UI_UNAVAILABLE",
397
+ message: "the selected Android display could not be prepared.",
398
+ recoverable: false,
399
+ userAction: "Check adb access and the selected device screen, then retry the launch.",
400
+ exitCode: EXIT.runtime,
401
+ originalError: attempt.output.trim()
402
+ });
403
+ }
384
404
  if (heal === "off") {
385
405
  return launchFail(jsonOutput, stream, adapter, mobileTarget, tier, state, target, {
386
406
  code: "LAUNCH_FAILED",
package/docs/QA.md CHANGED
@@ -105,6 +105,18 @@ mm-harness debug
105
105
 
106
106
  ## Mobile
107
107
 
108
+ Physical Android devices must be awake and unlocked. Launch wakes the selected
109
+ device, requests keyguard dismissal and checks the current user's lock state.
110
+ `MOBILE_DEVICE_LOCKED` requires a human unlock; `MOBILE_DEVICE_UI_UNAVAILABLE`
111
+ means the display or lock-state check failed. These prerequisites stop launch
112
+ without an automatic recovery restart.
113
+
114
+ For dedicated powered QA devices, configure stay-awake for the power sources
115
+ actually reported by Android. An ADB-connected phone can report AC power, so a
116
+ USB-only setting may still allow sleep. `adb -s <serial> shell svc power stayon true`
117
+ enables stay-awake on external power. Record the previous setting before changing
118
+ it, and restore it when returning a device to ordinary use.
119
+
108
120
  ```bash
109
121
  cd /path/to/metamask-mobile
110
122
  mm-harness provision runway ios
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@deeeed/metamask-harness",
3
- "version": "0.67.0",
3
+ "version": "0.67.1",
4
4
  "type": "module",
5
5
  "bin": {
6
6
  "mm-harness": "bin/mm-harness"