@deeeed/metamask-harness 0.34.1 → 0.34.3

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.
Files changed (53) hide show
  1. package/CHANGELOG.md +24 -0
  2. package/adapters/mobile/bridge-runtime/cdp-bridge.cjs +222 -0
  3. package/adapters/mobile/bridge-runtime/console-forwarder.cjs +22 -1
  4. package/adapters/mobile/bridge-runtime/lib/ws-client.cjs +18 -0
  5. package/adapters/mobile/open-device.sh +6 -4
  6. package/dist/adapters/mobile/prepare.js +1 -4
  7. package/dist/adapters.js +139 -8
  8. package/dist/commands/launch/mobile.js +16 -2
  9. package/dist/live-adapter-contract.js +6 -6
  10. package/dist/recipe-security.js +3 -0
  11. package/dist/runner.js +53 -8
  12. package/dist/runtime-context.js +1 -0
  13. package/library/actions/core/perps/_controller.mjs +75 -23
  14. package/library/actions/core/perps/read_account.mjs +6 -12
  15. package/library/actions/core/perps/read_snapshot.mjs +66 -0
  16. package/library/actions/extension/performance/_navigation-memory.mjs +372 -0
  17. package/library/actions/extension/performance/compare_idle_navigation_memory.mjs +18 -0
  18. package/library/actions/extension/performance/measure_detached_dom.mjs +18 -0
  19. package/library/actions/extension/performance/measure_navigation_memory.mjs +18 -0
  20. package/library/actions/mobile/app/network-control.mjs +24 -12
  21. package/library/actions/mobile/perps/measure_homepage_visible.mjs +4 -0
  22. package/library/actions/mobile/perps/performance-capture.mjs +506 -120
  23. package/library/actions/mobile/perps/perps.mjs +221 -38
  24. package/library/actions/mobile/perps/prepare_local_snapshot_endpoint.mjs +58 -0
  25. package/library/actions/mobile/platform/bridge.mjs +38 -5
  26. package/library/actions/mobile/wallet/ensure_unlocked.mjs +5 -1
  27. package/library/manifests/core.action-manifest.json +55 -0
  28. package/library/manifests/extension.action-manifest.json +100 -0
  29. package/library/manifests/mobile.action-manifest.json +226 -512
  30. package/library/recipes/core/perps/snapshot.recipe.json +25 -0
  31. package/library/recipes/extension/performance/navigation-memory.recipe.json +175 -0
  32. package/library/recipes/mobile/perps/performance.homepage.cold-position-sample.recipe.json +120 -0
  33. package/library/recipes/mobile/perps/performance.homepage.ios-background-reconnect.recipe.json +7 -6
  34. package/library/recipes/mobile/perps/performance.homepage.ios-cold-no-cache.recipe.json +5 -5
  35. package/package.json +1 -1
  36. package/scripts/site-contrast.mjs +39 -3
  37. package/site/architecture.html +22 -16
  38. package/site/assets/progress.mjs +1 -1
  39. package/site/assets/style.css +113 -1
  40. package/site/cheatsheet.html +14 -12
  41. package/site/how-it-works.html +693 -0
  42. package/site/index.html +70 -640
  43. package/site/perps.html +7 -6
  44. package/site/recipes.html +23 -17
  45. package/site/reviewers.html +7 -6
  46. package/site/tutorials/index.html +7 -6
  47. package/site/tutorials/v1.html +12 -11
  48. package/site/tutorials/v2.html +7 -6
  49. package/site/tutorials/v3.html +15 -11
  50. package/site/tutorials/v4.html +6 -5
  51. package/site/tutorials/v5.html +6 -5
  52. package/site/tutorials/v6.html +6 -5
  53. package/site/tutorials/v7.html +6 -5
package/CHANGELOG.md CHANGED
@@ -2,6 +2,30 @@
2
2
 
3
3
  ## Unreleased
4
4
 
5
+ ## 0.34.3 - 2026-08-12
6
+
7
+ ### Added
8
+
9
+ - Add a composable headless Core Perps snapshot recipe that calls the public controller APIs with configurable Terminal endpoints.
10
+ - Add route-agnostic Extension actions and a reusable recipe for post-GC navigation slopes, detached-DOM growth, and equal-duration idle-control memory measurement.
11
+
12
+ ### Fixed
13
+
14
+ - Preserve an already-foregrounded Android app while recipes toggle network state, avoiding an unnecessary launcher intent during recovery validation.
15
+
16
+ ## 0.34.2 - 2026-08-11
17
+
18
+ ### Added
19
+
20
+ - Add composable platform-neutral Homepage Perps position setup and background-lifecycle measurement recipes.
21
+
22
+ ### Fixed
23
+
24
+ - Give Mobile foreground lifecycle actions the same bounded 60-second runtime-reacquisition window as launch and restart.
25
+ - Classify iOS foreground as a same-process resume or cold relaunch, and let reconnect recipes reject process-evicted samples immediately.
26
+ - Quiesce active Perps streams before direct-CDP performance cache removal and verify the disk keys remain absent.
27
+ - Exclude queued pre-lifecycle socket frames from lifecycle-specific Fresh DFD measurements.
28
+
5
29
  ## 0.34.1 - 2026-08-09
6
30
 
7
31
  ### 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,11 +39,16 @@ 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"
45
47
  ]);
46
- const CORE_ONLY_PERPS_ACTIONS = /* @__PURE__ */ new Set(["metamask.perps.read_account"]);
48
+ const CORE_ONLY_PERPS_ACTIONS = /* @__PURE__ */ new Set([
49
+ "metamask.perps.read_account",
50
+ "metamask.perps.read_snapshot"
51
+ ]);
47
52
  const LIVE_ONLY_WALLET_ACTIONS = /* @__PURE__ */ new Set([
48
53
  "metamask.wallet.setup",
49
54
  "metamask.wallet.ensure_unlocked",
@@ -198,9 +203,13 @@ function createMetaMaskSemanticAdapters(platform, declaredCustomActions = [], pr
198
203
  "metamask.perps.start_state",
199
204
  "metamask.perps.teardown_state",
200
205
  "metamask.perps.capture_performance",
201
- ...platform === "mobile" ? ["metamask.perps.clear_performance_caches"] : [],
206
+ ...platform === "mobile" ? [
207
+ "metamask.perps.clear_performance_caches",
208
+ "metamask.perps.measure_homepage_visible",
209
+ "metamask.perps.prepare_local_snapshot_endpoint"
210
+ ] : [],
202
211
  // read_account is core-only: only the headless core adapter implements it.
203
- ...platform === "core" ? ["metamask.perps.read_account"] : []
212
+ ...platform === "core" ? ["metamask.perps.read_account", "metamask.perps.read_snapshot"] : []
204
213
  ];
205
214
  const bundled = new Set(bundledActions);
206
215
  const actions = [.../* @__PURE__ */ new Set([...bundledActions, ...declaredCustomActions])];
@@ -283,6 +292,17 @@ function resolveMobileWaitTarget(payload) {
283
292
  visibility: expected === "visible" || expected === "hidden" ? "viewport" : "tree"
284
293
  };
285
294
  }
295
+ const RETRYABLE_MOBILE_WAIT_CODES = /* @__PURE__ */ new Set([
296
+ MOBILE_BRIDGE_ERROR_CODES.NO_TARGET,
297
+ MOBILE_BRIDGE_ERROR_CODES.CDP_TIMEOUT,
298
+ MOBILE_BRIDGE_ERROR_CODES.WS_CLOSED,
299
+ MOBILE_BRIDGE_ERROR_CODES.METRO_UNREACHABLE
300
+ ]);
301
+ function isRetryableMobileWaitError(error) {
302
+ return Boolean(
303
+ isRecord(error) && typeof error.code === "string" && RETRYABLE_MOBILE_WAIT_CODES.has(error.code)
304
+ );
305
+ }
286
306
  async function waitForMobileTarget(input, payload) {
287
307
  const timeoutMs = Number(payload.timeout_ms ?? payload.timeoutMs ?? 1e4);
288
308
  const deadline = Date.now() + timeoutMs;
@@ -308,9 +328,28 @@ async function waitForMobileTarget(input, payload) {
308
328
  })()` : null;
309
329
  let lastResult = null;
310
330
  let lastText = null;
331
+ let lastBridgeError = null;
311
332
  const expectsAbsent = expected === "absent" || expected === "hidden" || expected === "not_present";
312
333
  while (Date.now() <= deadline) {
313
- const result = await evalAsync(input, queryExpression);
334
+ const remainingMs = Math.max(1, deadline - Date.now());
335
+ const probeTimeoutMs = Math.min(5e3, remainingMs);
336
+ const probeInput = {
337
+ ...input,
338
+ node: {
339
+ ...input.node,
340
+ bridge_timeout_ms: probeTimeoutMs,
341
+ cdp_timeout_ms: probeTimeoutMs
342
+ }
343
+ };
344
+ let result;
345
+ try {
346
+ result = await evalAsync(probeInput, queryExpression);
347
+ } catch (error) {
348
+ if (!isRetryableMobileWaitError(error)) throw error;
349
+ lastBridgeError = error;
350
+ await sleep(250);
351
+ continue;
352
+ }
314
353
  if (!isRecord(result) || result.unsupported === true) {
315
354
  throw new Error(
316
355
  "Mobile ui.wait_for requires the current __AGENTIC__.queryUiTarget bridge for text-only or viewport-visible assertions."
@@ -321,7 +360,14 @@ async function waitForMobileTarget(input, payload) {
321
360
  const visible = result.visible === true;
322
361
  let textMatched = !expectedText || textMatch === "contains";
323
362
  if (expectedText && textMatch === "exact" && textExpression) {
324
- lastText = await evalSync(input, textExpression);
363
+ try {
364
+ lastText = await evalSync(probeInput, textExpression);
365
+ } catch (error) {
366
+ if (!isRetryableMobileWaitError(error)) throw error;
367
+ lastBridgeError = error;
368
+ await sleep(250);
369
+ continue;
370
+ }
325
371
  textMatched = traceText(lastText) === expectedText;
326
372
  }
327
373
  const matched = expected === "visible" ? visible && textMatched : expected === "hidden" ? !visible : expectsAbsent ? !present : present && textMatched;
@@ -344,8 +390,9 @@ async function waitForMobileTarget(input, payload) {
344
390
  expectedText ? `text ${textMatch} ${JSON.stringify(expectedText)}` : ""
345
391
  ].filter(Boolean).join(" with ");
346
392
  const exactReason = textMatch === "exact" ? `; last text=${JSON.stringify(lastText)}` : "";
393
+ const bridgeReason = lastBridgeError ? `; last bridge error=${traceText(lastBridgeError)}` : "";
347
394
  throw new Error(
348
- `Timed out waiting for mobile ${target} to be ${expected}; last result=${JSON.stringify(lastResult)}${exactReason}.`
395
+ `Timed out waiting for mobile ${target} to be ${expected}; last result=${JSON.stringify(lastResult)}${exactReason}${bridgeReason}.`
349
396
  );
350
397
  }
351
398
  const MOBILE_BRIDGE_HANDLERS = {
@@ -427,6 +474,48 @@ async function handleMobileScroll(payload, context) {
427
474
  const testId = optionalScalarText(payload.test_id ?? payload.testID, "ui.scroll.test_id");
428
475
  const offset = scalarText(payload.offset ?? payload.delta_y ?? payload.deltaY, "ui.scroll.offset", "600");
429
476
  const intoView = payload.scroll_into_view === true || payload.into_view === true;
477
+ const measureFromTestId = optionalScalarText(
478
+ payload.measure_from_test_id ?? payload.measureFromTestId,
479
+ "ui.scroll.measure_from_test_id"
480
+ );
481
+ const measureTargetTestId = optionalScalarText(
482
+ payload.measure_target_test_id ?? payload.measureTargetTestId,
483
+ "ui.scroll.measure_target_test_id"
484
+ );
485
+ const measureTargetText = optionalScalarText(
486
+ payload.measure_target_text ?? payload.measureTargetText,
487
+ "ui.scroll.measure_target_text"
488
+ );
489
+ const measureTargetCount = Number(Boolean(measureTargetTestId)) + Number(Boolean(measureTargetText));
490
+ const measureRequiredPresentTestId = optionalScalarText(
491
+ payload.measure_required_present_test_id ?? payload.measureRequiredPresentTestId,
492
+ "ui.scroll.measure_required_present_test_id"
493
+ );
494
+ if (Boolean(measureFromTestId) !== (measureTargetCount === 1)) {
495
+ throw new Error(
496
+ "ui.scroll visible-transition measurement requires measure_from_test_id and exactly one of measure_target_test_id or measure_target_text."
497
+ );
498
+ }
499
+ if (measureFromTestId && measureTargetCount === 1) {
500
+ if (intoView) {
501
+ throw new Error(
502
+ "ui.scroll visible-transition measurement uses an explicit offset and does not support scroll_into_view."
503
+ );
504
+ }
505
+ return measureMobileScrollTransition(input, {
506
+ animated: payload.animated === true,
507
+ fromTestId: measureFromTestId,
508
+ offset: Number(offset),
509
+ ...measureTargetTestId ? { targetTestId: measureTargetTestId } : {},
510
+ ...measureTargetText ? { targetText: measureTargetText } : {},
511
+ ...measureRequiredPresentTestId ? { requiredPresentTestId: measureRequiredPresentTestId } : {},
512
+ timeoutMs: Math.max(
513
+ 1,
514
+ Number(payload.timeout_ms ?? payload.timeoutMs ?? 3e4)
515
+ ),
516
+ ...testId ? { scrollTestId: testId } : {}
517
+ });
518
+ }
430
519
  if (testId && intoView) {
431
520
  const before = await queryMobileViewportTarget(input, testId);
432
521
  if (before.visible === true) {
@@ -448,12 +537,23 @@ async function handleMobileScroll(payload, context) {
448
537
  ...intoView ? ["--into-view"] : [],
449
538
  animatedFlag(payload)
450
539
  ] : ["scroll-view", "--offset", offset, animatedFlag(payload)];
451
- const result = await bridgeCommand(input, args);
540
+ const timeoutMs = Math.max(0, Number(payload.timeout_ms ?? payload.timeoutMs ?? 5e3));
541
+ const deadline = Date.now() + timeoutMs;
542
+ let result;
543
+ while (true) {
544
+ try {
545
+ result = await bridgeCommand(input, args);
546
+ break;
547
+ } catch (error) {
548
+ if (!testId || Date.now() >= deadline) throw error;
549
+ await sleep(100);
550
+ }
551
+ }
452
552
  if (testId && intoView) {
453
553
  const after = await waitForMobileViewportTarget(
454
554
  input,
455
555
  testId,
456
- Number(payload.timeout_ms ?? payload.timeoutMs ?? 5e3)
556
+ timeoutMs
457
557
  );
458
558
  if (after.visible !== true) {
459
559
  throw new Error(
@@ -466,6 +566,36 @@ async function handleMobileScroll(payload, context) {
466
566
  intoView
467
567
  };
468
568
  }
569
+ async function measureMobileScrollTransition(input, options) {
570
+ const result = await bridgeCommand(
571
+ {
572
+ ...input,
573
+ node: {
574
+ ...input.node,
575
+ bridge_timeout_ms: options.timeoutMs + 5e3,
576
+ cdp_timeout_ms: options.timeoutMs + 5e3
577
+ }
578
+ },
579
+ ["measure-scroll-transition", JSON.stringify(options)]
580
+ );
581
+ if (!isRecord(result) || result.unsupported === true) {
582
+ throw new Error(
583
+ "ui.scroll visible-transition measurement requires queryUiTarget and scrollView on the current mobile bridge."
584
+ );
585
+ }
586
+ if (result.ok !== true) {
587
+ throw new Error(
588
+ `ui.scroll visible-transition measurement failed: ${traceText(result.error ?? result)}.`
589
+ );
590
+ }
591
+ return {
592
+ ...result,
593
+ measureFromTestId: options.fromTestId,
594
+ ...options.targetTestId ? { measureTargetTestId: options.targetTestId } : {},
595
+ ...options.targetText ? { measureTargetText: options.targetText } : {},
596
+ ...options.requiredPresentTestId ? { measureRequiredPresentTestId: options.requiredPresentTestId } : {}
597
+ };
598
+ }
469
599
  async function queryMobileViewportTarget(input, testId) {
470
600
  const expression = `(function(){
471
601
  const api = globalThis.__AGENTIC__;
@@ -851,6 +981,7 @@ export {
851
981
  createMetaMaskUiTransport,
852
982
  hideMobileHudOnTeardown,
853
983
  isMobileHudLifecycleSkip,
984
+ isRetryableMobileWaitError,
854
985
  normalizeUiWaitNode,
855
986
  resolveMobileWaitTarget
856
987
  };
@@ -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