@deeeed/metamask-harness 0.34.1 → 0.34.2

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,19 @@
2
2
 
3
3
  ## Unreleased
4
4
 
5
+ ## 0.34.2 - 2026-08-11
6
+
7
+ ### Added
8
+
9
+ - Add composable platform-neutral Homepage Perps position setup and background-lifecycle measurement recipes.
10
+
11
+ ### Fixed
12
+
13
+ - Give Mobile foreground lifecycle actions the same bounded 60-second runtime-reacquisition window as launch and restart.
14
+ - Classify iOS foreground as a same-process resume or cold relaunch, and let reconnect recipes reject process-evicted samples immediately.
15
+ - Quiesce active Perps streams before direct-CDP performance cache removal and verify the disk keys remain absent.
16
+ - Exclude queued pre-lifecycle socket frames from lifecycle-specific Fresh DFD measurements.
17
+
5
18
  ## 0.34.1 - 2026-08-09
6
19
 
7
20
  ### Fixed
@@ -30,6 +30,19 @@ const {
30
30
  const { cdpEval, cdpEvalAsync } = require('./lib/cdp-eval.cjs');
31
31
  const { buildArmSnippet, buildCollectSnippet } = require('./lib/issue-capture.cjs');
32
32
 
33
+ function parseHomepagePerformanceConsoleEvent(params) {
34
+ const marker = '[HomepagePerf] ';
35
+ for (const arg of params?.args || []) {
36
+ const text = typeof arg?.value === 'string' ? arg.value : '';
37
+ const markerIndex = text.indexOf(marker);
38
+ if (markerIndex < 0) continue;
39
+ try {
40
+ return JSON.parse(text.slice(markerIndex + marker.length));
41
+ } catch {}
42
+ }
43
+ return null;
44
+ }
45
+
33
46
  // ---------------------------------------------------------------------------
34
47
  // Commands
35
48
  // ---------------------------------------------------------------------------
@@ -387,6 +400,214 @@ const COMMANDS = {
387
400
  return { ...result, deviceName };
388
401
  },
389
402
 
403
+ async 'measure-scroll-transition'(client, args, { deviceName } = {}) {
404
+ let options;
405
+ try {
406
+ options = JSON.parse(args[0] || '{}');
407
+ } catch (error) {
408
+ throw new Error(`Invalid measure-scroll-transition options: ${error.message}`);
409
+ }
410
+ const timeoutMs = Number(options.timeoutMs);
411
+ const pollIntervalMs = Number(options.pollIntervalMs ?? 100);
412
+ const visibleEventStage = options.visibleEventStage || null;
413
+ const visibleEventGraceMs = Number(options.visibleEventGraceMs ?? 0);
414
+ const targetCount = Number(Boolean(options.targetTestId)) + Number(Boolean(options.targetText));
415
+ if (
416
+ !options.fromTestId ||
417
+ targetCount !== 1 ||
418
+ !Number.isFinite(timeoutMs) ||
419
+ timeoutMs <= 0 ||
420
+ !Number.isFinite(pollIntervalMs) ||
421
+ pollIntervalMs < 16 ||
422
+ (visibleEventStage !== null && typeof visibleEventStage !== 'string') ||
423
+ !Number.isFinite(visibleEventGraceMs) ||
424
+ visibleEventGraceMs < 0
425
+ ) {
426
+ throw new Error(
427
+ 'measure-scroll-transition requires fromTestId, exactly one targetTestId or targetText, a positive timeoutMs, pollIntervalMs >= 16, and a non-negative visibleEventGraceMs.',
428
+ );
429
+ }
430
+ const visibleEvents = [];
431
+ let removeConsoleListener = null;
432
+ if (visibleEventStage) {
433
+ removeConsoleListener = client.on('Runtime.consoleAPICalled', (params) => {
434
+ const payload = parseHomepagePerformanceConsoleEvent(params);
435
+ if (payload?.stage === visibleEventStage) visibleEvents.push(payload);
436
+ });
437
+ await client.send('Runtime.enable');
438
+ }
439
+ const targetQuery = options.targetTestId
440
+ ? { testId: options.targetTestId, visibility: 'viewport' }
441
+ : { textContains: options.targetText, visibility: 'viewport' };
442
+ const from = await cdpEvalAsync(
443
+ client,
444
+ `globalThis.__AGENTIC__?.queryUiTarget(${JSON.stringify({
445
+ testId: options.fromTestId,
446
+ visibility: 'viewport',
447
+ })})`,
448
+ timeoutMs,
449
+ );
450
+ if (from?.visible !== true) {
451
+ return {
452
+ ok: false,
453
+ error: 'measurement start target is not visible',
454
+ from,
455
+ deviceName,
456
+ };
457
+ }
458
+ let walletReady = null;
459
+ if (options.requireWalletReady === true) {
460
+ walletReady = await cdpEval(
461
+ client,
462
+ `(function(){
463
+ var route = globalThis.__AGENTIC__?.getRoute() || null;
464
+ var account = null;
465
+ try { account = globalThis.__AGENTIC__?.getSelectedAccount() || null; } catch(e) {}
466
+ var address = account && typeof account.address === 'string' ? account.address : '';
467
+ var id = account && typeof account.id === 'string' ? account.id : '';
468
+ return {
469
+ ready: Boolean(address || id) && String(route && route.name || '') !== 'Login',
470
+ route: route,
471
+ accountPresent: Boolean(address || id),
472
+ observedAtMs: performance.now(),
473
+ };
474
+ })()`,
475
+ );
476
+ if (walletReady?.ready !== true) {
477
+ return {
478
+ ok: false,
479
+ error: 'wallet is not ready at measurement start',
480
+ walletReady,
481
+ from,
482
+ deviceName,
483
+ };
484
+ }
485
+ }
486
+ const started = await cdpEvalAsync(
487
+ client,
488
+ `(function(){
489
+ const scrollStartedAtMs = performance.now();
490
+ const startedAtMs = ${
491
+ walletReady === null
492
+ ? 'scrollStartedAtMs'
493
+ : JSON.stringify(Number(walletReady.observedAtMs))
494
+ };
495
+ return Promise.resolve(globalThis.__AGENTIC__?.scrollView(${JSON.stringify({
496
+ testId: options.scrollTestId,
497
+ offset: options.offset,
498
+ animated: options.animated,
499
+ intoView: false,
500
+ })})).then(function(scroll){ return { startedAtMs, scrollStartedAtMs, scroll }; });
501
+ })()`,
502
+ timeoutMs,
503
+ );
504
+ if (started?.scroll?.ok === false) {
505
+ return {
506
+ ok: false,
507
+ error: 'scroll failed',
508
+ ...started,
509
+ from,
510
+ deviceName,
511
+ };
512
+ }
513
+ const hostDeadline = Date.now() + timeoutMs;
514
+ if (visibleEventStage && visibleEventGraceMs > 0) {
515
+ const eventDeadline = Math.min(hostDeadline, Date.now() + visibleEventGraceMs);
516
+ while (Date.now() <= eventDeadline) {
517
+ const event = visibleEvents.find(
518
+ (candidate) =>
519
+ Number(candidate.frame_checkpoint_monotonic_ms) >=
520
+ Number(started.scrollStartedAtMs),
521
+ );
522
+ if (event) {
523
+ removeConsoleListener?.();
524
+ const observedAtMs = Number(event.frame_checkpoint_monotonic_ms);
525
+ return {
526
+ ok: true,
527
+ clock: 'performance.now',
528
+ observationSource: 'runtime-console-event',
529
+ durationMs: observedAtMs - started.startedAtMs,
530
+ scrollDurationMs: observedAtMs - started.scrollStartedAtMs,
531
+ appVisibleDurationMs: Number(event.duration_ms),
532
+ pollIntervalMs,
533
+ preScrollDelayMs: started.scrollStartedAtMs - started.startedAtMs,
534
+ startedAtMs: started.startedAtMs,
535
+ scrollStartedAtMs: started.scrollStartedAtMs,
536
+ observedAtMs,
537
+ visibleEvent: event,
538
+ ...(walletReady ? { walletReady } : {}),
539
+ from,
540
+ scroll: started.scroll,
541
+ deviceName,
542
+ };
543
+ }
544
+ await new Promise((resolve) => setTimeout(resolve, 25));
545
+ }
546
+ }
547
+ while (Date.now() <= hostDeadline) {
548
+ const remainingMs = hostDeadline - Date.now();
549
+ if (remainingMs <= 1_000) break;
550
+ const queryTimeoutMs = Math.min(5_000, remainingMs);
551
+ const observation = await cdpEvalAsync(
552
+ client,
553
+ `(function(){
554
+ return Promise.resolve(globalThis.__AGENTIC__?.queryUiTarget(${JSON.stringify(targetQuery)})).then(function(target){
555
+ return { target, observedAtMs: performance.now() };
556
+ });
557
+ })()`,
558
+ queryTimeoutMs,
559
+ );
560
+ const requiredPresent =
561
+ observation?.target?.visible === true && options.requiredPresentTestId
562
+ ? await cdpEvalAsync(
563
+ client,
564
+ `globalThis.__AGENTIC__?.queryUiTarget(${JSON.stringify({
565
+ testId: options.requiredPresentTestId,
566
+ visibility: 'tree',
567
+ })})`,
568
+ queryTimeoutMs,
569
+ )
570
+ : null;
571
+ if (
572
+ observation?.target?.visible === true &&
573
+ (!options.requiredPresentTestId || requiredPresent?.present === true)
574
+ ) {
575
+ return {
576
+ ok: true,
577
+ clock: 'performance.now',
578
+ durationMs: observation.observedAtMs - started.startedAtMs,
579
+ scrollDurationMs:
580
+ observation.observedAtMs - started.scrollStartedAtMs,
581
+ pollIntervalMs,
582
+ observationSource: visibleEventStage
583
+ ? 'fiber-poll-after-event-grace'
584
+ : 'fiber-poll',
585
+ preScrollDelayMs:
586
+ started.scrollStartedAtMs - started.startedAtMs,
587
+ startedAtMs: started.startedAtMs,
588
+ scrollStartedAtMs: started.scrollStartedAtMs,
589
+ observedAtMs: observation.observedAtMs,
590
+ ...(walletReady ? { walletReady } : {}),
591
+ from,
592
+ target: observation.target,
593
+ ...(requiredPresent ? { requiredPresent } : {}),
594
+ scroll: started.scroll,
595
+ deviceName,
596
+ };
597
+ }
598
+ await new Promise((resolve) => setTimeout(resolve, pollIntervalMs));
599
+ }
600
+ removeConsoleListener?.();
601
+ return {
602
+ ok: false,
603
+ error: 'timed out waiting for visible transition target',
604
+ durationMs: await cdpEval(client, `performance.now() - ${Number(started.startedAtMs)}`),
605
+ startedAtMs: started.startedAtMs,
606
+ scrollStartedAtMs: started.scrollStartedAtMs,
607
+ deviceName,
608
+ };
609
+ },
610
+
390
611
  async 'set-input'(client, args, { deviceName } = {}) {
391
612
  const testId = args[0];
392
613
  const value = args.slice(1).join(' ');
@@ -754,6 +975,7 @@ Commands:
754
975
  press-text <text> Press a component containing visible text
755
976
  scroll-view [--test-id <id>] [--offset <n>] [--animated]
756
977
  Scroll a ScrollView/FlatList
978
+ measure-scroll-transition <json> Scroll and measure a visible target using one CDP session
757
979
  set-input <testId> <value> Set text input value by testID (calls onChangeText)
758
980
  sentry-debug [enable|disable] Patch Sentry to log errors to console with [SENTRY-DEBUG] prefix
759
981
  unlock <password> Unlock wallet (inject password + press login button via fiber tree)
@@ -61,6 +61,11 @@ const statePath = `${out}.forwarder-state.json`;
61
61
  // buffer replay backfills everything missed, so no lines are lost.
62
62
  const LOCK_FILE = path.join(path.dirname(out), 'cdp-bridge.lock');
63
63
  const LOCK_SETTLE_MS = 1500;
64
+ // The inspector can deliver NEW_DEBUGGER_OPENED after a short bridge command
65
+ // has already removed its lock. Keep a narrow release grace so that delayed
66
+ // close event is not mistaken for a human DevTools takeover (which otherwise
67
+ // suppresses device logs for five minutes).
68
+ const BRIDGE_RELEASE_GRACE_MS = 3000;
64
69
  const LOCK_STALE_MS = 30000; // unreadable lock body: crashed bridge must not block logs forever
65
70
  // dev-middleware serves one debugger slot per device; when a debugger we do not
66
71
  // coordinate with (React Native DevTools) takes it, re-attaching would evict
@@ -105,9 +110,20 @@ function yieldSessions() {
105
110
  }
106
111
 
107
112
  let resumeTimer = null;
113
+ let bridgeCoordinationUntil = 0;
114
+
115
+ function noteBridgeCoordination() {
116
+ bridgeCoordinationUntil = Date.now() + BRIDGE_RELEASE_GRACE_MS;
117
+ }
118
+
119
+ function bridgeCoordinationRecent() {
120
+ return Date.now() < bridgeCoordinationUntil;
121
+ }
122
+
108
123
  try {
109
124
  fs.watch(path.dirname(out), (_event, filename) => {
110
125
  if (filename !== path.basename(LOCK_FILE)) return;
126
+ noteBridgeCoordination();
111
127
  if (bridgeLockActive()) {
112
128
  if (resumeTimer) {
113
129
  clearTimeout(resumeTimer);
@@ -272,7 +288,11 @@ function connect(target) {
272
288
  // another one attaches. With no bridge lock present that debugger is a
273
289
  // human's DevTools session — back off long instead of evicting them back.
274
290
  const why = event && event.reason ? String(event.reason) : '';
275
- if (why.includes('NEW_DEBUGGER_OPENED') && !bridgeLockActive()) {
291
+ if (
292
+ why.includes('NEW_DEBUGGER_OPENED') &&
293
+ !bridgeLockActive() &&
294
+ !bridgeCoordinationRecent()
295
+ ) {
276
296
  foreignDebuggerUntil.set(deviceId, Date.now() + FOREIGN_DEBUGGER_BACKOFF_MS);
277
297
  process.stderr.write(
278
298
  `console-forwarder: another debugger took ${name}; standing down for ${FOREIGN_DEBUGGER_BACKOFF_MS / 60000} min\n`,
@@ -285,6 +305,7 @@ function connect(target) {
285
305
 
286
306
  function discover() {
287
307
  if (bridgeLockActive()) {
308
+ noteBridgeCoordination();
288
309
  schedule(DISCOVER_ACTIVE_MS);
289
310
  return;
290
311
  }
@@ -31,6 +31,7 @@ function createWSClient(wsUrl, timeout) {
31
31
  const ws = new WebSocketImpl(wsUrl);
32
32
  let msgId = 0;
33
33
  const pending = new Map();
34
+ const eventHandlers = new Map();
34
35
 
35
36
  const timer = setTimeout(() => {
36
37
  ws.close();
@@ -69,6 +70,15 @@ function createWSClient(wsUrl, timeout) {
69
70
  ws.send(msg);
70
71
  });
71
72
  },
73
+ on(method, handler) {
74
+ const handlers = eventHandlers.get(method) || new Set();
75
+ handlers.add(handler);
76
+ eventHandlers.set(method, handlers);
77
+ return () => {
78
+ handlers.delete(handler);
79
+ if (handlers.size === 0) eventHandlers.delete(method);
80
+ };
81
+ },
72
82
  close() {
73
83
  ws.close();
74
84
  },
@@ -92,6 +102,13 @@ function createWSClient(wsUrl, timeout) {
92
102
  } else {
93
103
  res(msg.result);
94
104
  }
105
+ return;
106
+ }
107
+ if (!msg.method) return;
108
+ for (const handler of eventHandlers.get(msg.method) || []) {
109
+ try {
110
+ handler(msg.params || {});
111
+ } catch {}
95
112
  }
96
113
  };
97
114
 
@@ -106,6 +123,7 @@ function createWSClient(wsUrl, timeout) {
106
123
  rej(coded(new Error('WebSocket closed'), BRIDGE_ERROR_CODES.WS_CLOSED));
107
124
  }
108
125
  pending.clear();
126
+ eventHandlers.clear();
109
127
  };
110
128
  });
111
129
  }
@@ -522,10 +522,6 @@ else
522
522
  ADB_ARGS=()
523
523
  [ -n "$ADB_SERIAL_ARG" ] && ADB_ARGS+=("-s" "$ADB_SERIAL_ARG")
524
524
 
525
- # Reverse-tunnel Metro port so the device can reach localhost:PORT.
526
- "$ADB_BIN" "${ADB_ARGS[@]}" reverse "tcp:${PORT}" "tcp:${PORT}" >/dev/null 2>&1 || \
527
- printf 'open-device: adb reverse failed (non-fatal — device may already be reachable)\n' >&2
528
-
529
525
  LAUNCHED=false
530
526
  for PKG in "${ANDROID_PKGS[@]}"; do
531
527
  if ! ensure_android_app_for_mode "$PKG" "$PREFLIGHT_MODE" "${ADB_ARGS[@]}"; then
@@ -539,6 +535,12 @@ else
539
535
  printf 'Restarting Android dev client %s\n' "$PKG" >&2
540
536
  fi
541
537
 
538
+ # Installing/rebuilding a dev client can invalidate adb reverse mappings.
539
+ # Restore Metro reachability only after the app is ensured, immediately
540
+ # before opening the development-client URL.
541
+ "$ADB_BIN" "${ADB_ARGS[@]}" reverse "tcp:${PORT}" "tcp:${PORT}" >/dev/null 2>&1 || \
542
+ printf 'open-device: adb reverse failed (non-fatal — device may already be reachable)\n' >&2
543
+
542
544
  ENCODED_URL="$(urlencode "http://localhost:${PORT}?disableOnboarding=1" 2>/dev/null \
543
545
  || printf 'http%%3A%%2F%%2Flocalhost%%3A%s%%3FdisableOnboarding%%3D1' "$PORT")"
544
546
  DEEP_LINK="expo-metamask://expo-development-client/?url=${ENCODED_URL}"
@@ -116,10 +116,7 @@ async function prepareMobile(target, opts = {}) {
116
116
  );
117
117
  }
118
118
  const launch = await dispatchActionSequence(
119
- withAppRestart(
120
- launchActions(path.resolve(target)),
121
- restartApp || iosAccessibilityChanged
122
- ),
119
+ withAppRestart(launchActions(path.resolve(target)), true),
123
120
  target,
124
121
  platform,
125
122
  json,
package/dist/adapters.js CHANGED
@@ -39,6 +39,8 @@ const LIVE_ONLY_PERPS_ACTIONS = /* @__PURE__ */ new Set([
39
39
  "metamask.perps.ensure_orders",
40
40
  "metamask.perps.assert_orders",
41
41
  "metamask.perps.clear_performance_caches",
42
+ "metamask.perps.measure_homepage_visible",
43
+ "metamask.perps.prepare_local_snapshot_endpoint",
42
44
  "metamask.perps.start_state",
43
45
  "metamask.perps.teardown_state",
44
46
  "metamask.perps.capture_performance"
@@ -198,7 +200,11 @@ function createMetaMaskSemanticAdapters(platform, declaredCustomActions = [], pr
198
200
  "metamask.perps.start_state",
199
201
  "metamask.perps.teardown_state",
200
202
  "metamask.perps.capture_performance",
201
- ...platform === "mobile" ? ["metamask.perps.clear_performance_caches"] : [],
203
+ ...platform === "mobile" ? [
204
+ "metamask.perps.clear_performance_caches",
205
+ "metamask.perps.measure_homepage_visible",
206
+ "metamask.perps.prepare_local_snapshot_endpoint"
207
+ ] : [],
202
208
  // read_account is core-only: only the headless core adapter implements it.
203
209
  ...platform === "core" ? ["metamask.perps.read_account"] : []
204
210
  ];
@@ -283,6 +289,17 @@ function resolveMobileWaitTarget(payload) {
283
289
  visibility: expected === "visible" || expected === "hidden" ? "viewport" : "tree"
284
290
  };
285
291
  }
292
+ const RETRYABLE_MOBILE_WAIT_CODES = /* @__PURE__ */ new Set([
293
+ MOBILE_BRIDGE_ERROR_CODES.NO_TARGET,
294
+ MOBILE_BRIDGE_ERROR_CODES.CDP_TIMEOUT,
295
+ MOBILE_BRIDGE_ERROR_CODES.WS_CLOSED,
296
+ MOBILE_BRIDGE_ERROR_CODES.METRO_UNREACHABLE
297
+ ]);
298
+ function isRetryableMobileWaitError(error) {
299
+ return Boolean(
300
+ isRecord(error) && typeof error.code === "string" && RETRYABLE_MOBILE_WAIT_CODES.has(error.code)
301
+ );
302
+ }
286
303
  async function waitForMobileTarget(input, payload) {
287
304
  const timeoutMs = Number(payload.timeout_ms ?? payload.timeoutMs ?? 1e4);
288
305
  const deadline = Date.now() + timeoutMs;
@@ -308,9 +325,28 @@ async function waitForMobileTarget(input, payload) {
308
325
  })()` : null;
309
326
  let lastResult = null;
310
327
  let lastText = null;
328
+ let lastBridgeError = null;
311
329
  const expectsAbsent = expected === "absent" || expected === "hidden" || expected === "not_present";
312
330
  while (Date.now() <= deadline) {
313
- const result = await evalAsync(input, queryExpression);
331
+ const remainingMs = Math.max(1, deadline - Date.now());
332
+ const probeTimeoutMs = Math.min(5e3, remainingMs);
333
+ const probeInput = {
334
+ ...input,
335
+ node: {
336
+ ...input.node,
337
+ bridge_timeout_ms: probeTimeoutMs,
338
+ cdp_timeout_ms: probeTimeoutMs
339
+ }
340
+ };
341
+ let result;
342
+ try {
343
+ result = await evalAsync(probeInput, queryExpression);
344
+ } catch (error) {
345
+ if (!isRetryableMobileWaitError(error)) throw error;
346
+ lastBridgeError = error;
347
+ await sleep(250);
348
+ continue;
349
+ }
314
350
  if (!isRecord(result) || result.unsupported === true) {
315
351
  throw new Error(
316
352
  "Mobile ui.wait_for requires the current __AGENTIC__.queryUiTarget bridge for text-only or viewport-visible assertions."
@@ -321,7 +357,14 @@ async function waitForMobileTarget(input, payload) {
321
357
  const visible = result.visible === true;
322
358
  let textMatched = !expectedText || textMatch === "contains";
323
359
  if (expectedText && textMatch === "exact" && textExpression) {
324
- lastText = await evalSync(input, textExpression);
360
+ try {
361
+ lastText = await evalSync(probeInput, textExpression);
362
+ } catch (error) {
363
+ if (!isRetryableMobileWaitError(error)) throw error;
364
+ lastBridgeError = error;
365
+ await sleep(250);
366
+ continue;
367
+ }
325
368
  textMatched = traceText(lastText) === expectedText;
326
369
  }
327
370
  const matched = expected === "visible" ? visible && textMatched : expected === "hidden" ? !visible : expectsAbsent ? !present : present && textMatched;
@@ -344,8 +387,9 @@ async function waitForMobileTarget(input, payload) {
344
387
  expectedText ? `text ${textMatch} ${JSON.stringify(expectedText)}` : ""
345
388
  ].filter(Boolean).join(" with ");
346
389
  const exactReason = textMatch === "exact" ? `; last text=${JSON.stringify(lastText)}` : "";
390
+ const bridgeReason = lastBridgeError ? `; last bridge error=${traceText(lastBridgeError)}` : "";
347
391
  throw new Error(
348
- `Timed out waiting for mobile ${target} to be ${expected}; last result=${JSON.stringify(lastResult)}${exactReason}.`
392
+ `Timed out waiting for mobile ${target} to be ${expected}; last result=${JSON.stringify(lastResult)}${exactReason}${bridgeReason}.`
349
393
  );
350
394
  }
351
395
  const MOBILE_BRIDGE_HANDLERS = {
@@ -427,6 +471,48 @@ async function handleMobileScroll(payload, context) {
427
471
  const testId = optionalScalarText(payload.test_id ?? payload.testID, "ui.scroll.test_id");
428
472
  const offset = scalarText(payload.offset ?? payload.delta_y ?? payload.deltaY, "ui.scroll.offset", "600");
429
473
  const intoView = payload.scroll_into_view === true || payload.into_view === true;
474
+ const measureFromTestId = optionalScalarText(
475
+ payload.measure_from_test_id ?? payload.measureFromTestId,
476
+ "ui.scroll.measure_from_test_id"
477
+ );
478
+ const measureTargetTestId = optionalScalarText(
479
+ payload.measure_target_test_id ?? payload.measureTargetTestId,
480
+ "ui.scroll.measure_target_test_id"
481
+ );
482
+ const measureTargetText = optionalScalarText(
483
+ payload.measure_target_text ?? payload.measureTargetText,
484
+ "ui.scroll.measure_target_text"
485
+ );
486
+ const measureTargetCount = Number(Boolean(measureTargetTestId)) + Number(Boolean(measureTargetText));
487
+ const measureRequiredPresentTestId = optionalScalarText(
488
+ payload.measure_required_present_test_id ?? payload.measureRequiredPresentTestId,
489
+ "ui.scroll.measure_required_present_test_id"
490
+ );
491
+ if (Boolean(measureFromTestId) !== (measureTargetCount === 1)) {
492
+ throw new Error(
493
+ "ui.scroll visible-transition measurement requires measure_from_test_id and exactly one of measure_target_test_id or measure_target_text."
494
+ );
495
+ }
496
+ if (measureFromTestId && measureTargetCount === 1) {
497
+ if (intoView) {
498
+ throw new Error(
499
+ "ui.scroll visible-transition measurement uses an explicit offset and does not support scroll_into_view."
500
+ );
501
+ }
502
+ return measureMobileScrollTransition(input, {
503
+ animated: payload.animated === true,
504
+ fromTestId: measureFromTestId,
505
+ offset: Number(offset),
506
+ ...measureTargetTestId ? { targetTestId: measureTargetTestId } : {},
507
+ ...measureTargetText ? { targetText: measureTargetText } : {},
508
+ ...measureRequiredPresentTestId ? { requiredPresentTestId: measureRequiredPresentTestId } : {},
509
+ timeoutMs: Math.max(
510
+ 1,
511
+ Number(payload.timeout_ms ?? payload.timeoutMs ?? 3e4)
512
+ ),
513
+ ...testId ? { scrollTestId: testId } : {}
514
+ });
515
+ }
430
516
  if (testId && intoView) {
431
517
  const before = await queryMobileViewportTarget(input, testId);
432
518
  if (before.visible === true) {
@@ -448,12 +534,23 @@ async function handleMobileScroll(payload, context) {
448
534
  ...intoView ? ["--into-view"] : [],
449
535
  animatedFlag(payload)
450
536
  ] : ["scroll-view", "--offset", offset, animatedFlag(payload)];
451
- const result = await bridgeCommand(input, args);
537
+ const timeoutMs = Math.max(0, Number(payload.timeout_ms ?? payload.timeoutMs ?? 5e3));
538
+ const deadline = Date.now() + timeoutMs;
539
+ let result;
540
+ while (true) {
541
+ try {
542
+ result = await bridgeCommand(input, args);
543
+ break;
544
+ } catch (error) {
545
+ if (!testId || Date.now() >= deadline) throw error;
546
+ await sleep(100);
547
+ }
548
+ }
452
549
  if (testId && intoView) {
453
550
  const after = await waitForMobileViewportTarget(
454
551
  input,
455
552
  testId,
456
- Number(payload.timeout_ms ?? payload.timeoutMs ?? 5e3)
553
+ timeoutMs
457
554
  );
458
555
  if (after.visible !== true) {
459
556
  throw new Error(
@@ -466,6 +563,36 @@ async function handleMobileScroll(payload, context) {
466
563
  intoView
467
564
  };
468
565
  }
566
+ async function measureMobileScrollTransition(input, options) {
567
+ const result = await bridgeCommand(
568
+ {
569
+ ...input,
570
+ node: {
571
+ ...input.node,
572
+ bridge_timeout_ms: options.timeoutMs + 5e3,
573
+ cdp_timeout_ms: options.timeoutMs + 5e3
574
+ }
575
+ },
576
+ ["measure-scroll-transition", JSON.stringify(options)]
577
+ );
578
+ if (!isRecord(result) || result.unsupported === true) {
579
+ throw new Error(
580
+ "ui.scroll visible-transition measurement requires queryUiTarget and scrollView on the current mobile bridge."
581
+ );
582
+ }
583
+ if (result.ok !== true) {
584
+ throw new Error(
585
+ `ui.scroll visible-transition measurement failed: ${traceText(result.error ?? result)}.`
586
+ );
587
+ }
588
+ return {
589
+ ...result,
590
+ measureFromTestId: options.fromTestId,
591
+ ...options.targetTestId ? { measureTargetTestId: options.targetTestId } : {},
592
+ ...options.targetText ? { measureTargetText: options.targetText } : {},
593
+ ...options.requiredPresentTestId ? { measureRequiredPresentTestId: options.requiredPresentTestId } : {}
594
+ };
595
+ }
469
596
  async function queryMobileViewportTarget(input, testId) {
470
597
  const expression = `(function(){
471
598
  const api = globalThis.__AGENTIC__;
@@ -851,6 +978,7 @@ export {
851
978
  createMetaMaskUiTransport,
852
979
  hideMobileHudOnTeardown,
853
980
  isMobileHudLifecycleSkip,
981
+ isRetryableMobileWaitError,
854
982
  normalizeUiWaitNode,
855
983
  resolveMobileWaitTarget
856
984
  };
@@ -1,18 +1,32 @@
1
1
  import { prepareMobile } from "../../adapters/mobile/prepare.js";
2
+ import {
3
+ mobileSourceCheck,
4
+ recordMobileSourceBaseline
5
+ } from "../../adapters/mobile/source-freshness.js";
2
6
  import { ensureHarnessFresh } from "../../adapters/harness-freshness.js";
7
+ import { EXIT } from "../shared.js";
3
8
  async function launchMobile(target, mobileTarget, tier, json, restartApp = false, clearMetro = false) {
4
9
  await ensureHarnessFresh(target, "mobile");
5
10
  const platform = mobileTarget ?? "ios";
6
11
  const watcherPort = process.env.WATCHER_PORT ? parseInt(process.env.WATCHER_PORT, 10) : void 0;
7
12
  const preflightMode = tier === "build" ? "rebuild-native" : "fast";
8
- return prepareMobile(target, {
13
+ const source = mobileSourceCheck(target);
14
+ const result = await prepareMobile(target, {
9
15
  platform,
10
16
  json,
11
17
  watcherPort,
12
18
  preflightMode,
13
- restartApp,
19
+ restartApp: restartApp || source.status !== "current",
14
20
  clearMetro
15
21
  });
22
+ if (result.status !== 0) return result;
23
+ if (!recordMobileSourceBaseline(target, source.fingerprint)) {
24
+ const message = "Mobile source changed while the app was launching; the loaded-source baseline was not recorded.\n Next: re-run mm-harness launch.";
25
+ if (!json) process.stderr.write(`${message}
26
+ `);
27
+ return { status: EXIT.runtime, output: message };
28
+ }
29
+ return result;
16
30
  }
17
31
  export {
18
32
  launchMobile
@@ -352,15 +352,15 @@ function liveAdapterProcessTimeoutMs(node) {
352
352
  }
353
353
  const settleMs = Number(node.settle_ms);
354
354
  const settleAllowance = Number.isFinite(settleMs) && settleMs > 0 ? settleMs : 0;
355
- if (node.timeout_ms != null) {
356
- const timeoutMs = Number(node.timeout_ms);
357
- return Number.isFinite(timeoutMs) && timeoutMs > 0 ? timeoutMs + settleAllowance + 5e3 : 6e4;
358
- }
359
- const actionTimeouts = [node.target_timeout_ms, node.unlock_timeout_ms].map(Number).filter((value) => Number.isFinite(value) && value > 0);
355
+ const actionTimeouts = [
356
+ node.target_timeout_ms,
357
+ node.unlock_timeout_ms,
358
+ node.timeout_ms
359
+ ].map(Number).filter((value) => Number.isFinite(value) && value > 0);
360
360
  if (actionTimeouts.length > 0) {
361
361
  return Math.max(
362
362
  6e4,
363
- actionTimeouts.reduce((total, value) => total + value, 0) + 5e3
363
+ actionTimeouts.reduce((total, value) => total + value, 0) + settleAllowance + 5e3
364
364
  );
365
365
  }
366
366
  return 6e4 + settleAllowance;
@@ -29,6 +29,8 @@ const APP_MUTATION_CUSTOM_ACTIONS = /* @__PURE__ */ new Set([
29
29
  "metamask.perps.ensure_positions",
30
30
  "metamask.perps.ensure_orders",
31
31
  "metamask.perps.clear_performance_caches",
32
+ "metamask.perps.measure_homepage_visible",
33
+ "metamask.perps.prepare_local_snapshot_endpoint",
32
34
  "metamask.perps.start_state",
33
35
  "metamask.perps.teardown_state",
34
36
  // Consent changes wallet state and is therefore an app mutation.