@deeeed/metamask-harness 0.42.0 → 0.44.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.
Files changed (43) hide show
  1. package/CHANGELOG.md +42 -0
  2. package/README.md +5 -0
  3. package/adapters/mobile/bridge-runtime/cdp-bridge.cjs +363 -55
  4. package/adapters/mobile/bridge-runtime/console-forwarder.cjs +23 -10
  5. package/adapters/mobile/bridge-runtime/lib/bridge-errors.cjs +2 -0
  6. package/adapters/mobile/bridge-runtime/lib/cdp-broker.cjs +296 -25
  7. package/adapters/mobile/bridge-runtime/lib/config.cjs +14 -4
  8. package/adapters/mobile/bridge-runtime/lib/target-discovery.cjs +4 -2
  9. package/adapters/mobile/reload-app.mjs +99 -1
  10. package/adapters/mobile/start-console-forwarder.sh +5 -1
  11. package/dist/adapters/extension/browser-cdp.js +174 -0
  12. package/dist/adapters/extension/network-observer.js +19 -110
  13. package/dist/adapters/extension/performance-observer.js +75 -0
  14. package/dist/adapters/mobile/frame-metrics.js +45 -0
  15. package/dist/adapters/mobile/performance-observer.js +43 -0
  16. package/dist/adapters/mobile/prepare.js +12 -0
  17. package/dist/adapters/performance/cdp-trace.js +342 -0
  18. package/dist/adapters/performance/js-task-metrics.js +35 -0
  19. package/dist/adapters.js +39 -5
  20. package/dist/artifact-files.js +92 -0
  21. package/dist/async.js +19 -0
  22. package/dist/commands/call.js +63 -4
  23. package/dist/commands/run-engine.js +265 -139
  24. package/dist/commands/run-report.js +68 -0
  25. package/dist/commands/run.js +67 -3
  26. package/dist/execution-provenance.js +342 -0
  27. package/dist/network-observation.js +59 -47
  28. package/dist/performance-observation.js +465 -0
  29. package/dist/run-diagnostics.js +36 -11
  30. package/dist/runner.js +44 -13
  31. package/docs/PERFORMANCE-CAPTURE.md +33 -0
  32. package/docs/RECIPES.md +7 -0
  33. package/library/actions/mobile/perps/performance-capture.mjs +570 -189
  34. package/library/actions/mobile/perps/perps.mjs +122 -0
  35. package/library/actions/mobile/platform/bridge.mjs +43 -8
  36. package/library/actions/mobile/platform/native-session.mjs +1 -1
  37. package/library/actions/mobile/wallet/lock.mjs +1 -4
  38. package/library/actions/mobile/wallet/select_account.mjs +129 -17
  39. package/library/manifests/extension.action-manifest.json +85 -0
  40. package/library/manifests/mobile.action-manifest.json +125 -3
  41. package/library/recipes/mobile/perps/performance.recipe.json +73 -47
  42. package/package.json +1 -1
  43. package/scripts/site-contrast.mjs +43 -27
package/CHANGELOG.md CHANGED
@@ -2,6 +2,48 @@
2
2
 
3
3
  ## Unreleased
4
4
 
5
+ ## 0.44.0 - 2026-08-25
6
+
7
+ ### Fixed
8
+
9
+ - Remove `cold_disk_cache` from the `perps.performance` lifecycle enum because disk hydration is a cache source within a lifecycle, not a standalone lifecycle.
10
+ - Await promise-returning Mobile `press-test-id` and `set-input` bridge handlers so recipe nodes finish only after the in-app operation settles.
11
+ - Keep Mobile broker commands available while Hermes installs the in-app bridge after a target handoff, instead of reconnecting the inspector until actions time out.
12
+ - Honor `launch --build` when a healthy Mobile runtime is already connected; the explicit native build can no longer return a false pass without invoking the build leaf.
13
+ - Let resident Homepage and short-resume performance recipes finish from the ordered visible-content sequence without requiring a new live takeover.
14
+ - Make Mobile reload stay on the pinned device, fail before an active Android Hermes sampler can abort the app, and fail if the process exits or a replacement target never appears; reload never relaunches the app.
15
+ - Bind run evidence to the executable Harness source, build, bin, package, library, and adapter inputs plus task-local command/config helpers, and reject input drift or symlink swaps.
16
+ - Persist recovery and mutation lists in recipe packages, include a redacted run-scoped application log, reject failed recovery before retrying, and prove lifecycle continuity with native process IDs, including opaque runtimes when requested.
17
+ - Refuse stale Mobile source under every heal policy unless an authored `app.lifecycle restart` reloads it, and invalidate run evidence if recipe, library, or product provenance drifts after preparation.
18
+ - Require Homepage demand, committed UI, resolved content, and live-current frame records before the canonical Mobile Perps performance recipe can pass.
19
+ - Fail a Mobile recipe or action without implicitly restarting Metro or the app; process restarts remain explicit lifecycle actions.
20
+ - Keep one Mobile inspector connection open while React Native replays its console buffer, avoiding repeated `Runtime.enable` requests that can stall Metro after unlock.
21
+ - Allow `metamask.wallet.lock` to use the same visible Android controls on development and opaque clients.
22
+ - Keep account-selection proof attached across the temporary Hermes/CDP stall caused by the account switch itself.
23
+ - Add `metamask.perps.ensure_mode` so recipes can reach Lite or Pro through the visible mode control without inheriting prior device state.
24
+ - Preserve Homepage boundary source and content-variant metadata in performance summaries, and bind recipe manifests to the exact product checkout commit.
25
+ - Accept only structured, generation-coherent Perps live-stream records as performance proof, and keep Mobile wallet and mode probes inside one absolute action deadline.
26
+ - Keep the latest automatic Mobile HUD update in the CDP broker and retry it until the in-app bridge applies it when the pinned Hermes target returns, without restarting the app.
27
+ - Keep the Perps performance recipe's empty-account setup read-only and allow its setup-only prime capture to contain no performance records.
28
+ - Redact quoted JSON credential fields before persisting run-scoped application logs.
29
+
30
+ ## 0.43.0 - 2026-08-21
31
+
32
+ ### Added
33
+
34
+ - Add platform-neutral `app.performance_capture` and `app.performance_assert` actions that attribute Mobile or Extension CDP trace samples to exact recipe-node boundaries and emit JSON plus self-contained HTML evidence.
35
+ - Mobile native UI FPS requires a development client that reports `unstable_frameRecordingEnabled: true`; unpatched clients report partial or unavailable native coverage. Extension uses Chromium CDP and needs no client patch.
36
+
37
+ ### Fixed
38
+
39
+ - Bound performance capture duration, discard raw samples after node attribution, correct iOS frame completion timestamps, fail closed on unresolved Extension renderers, and reset CDP collector state after trace failures.
40
+ - Scope Mobile Hermes broker ownership by checkout runtime and Metro port so a forwarder from a prior port cannot intercept bridge, HUD, or observation commands after a slot port change.
41
+ - Preserve explicit Mobile `--device` pins when slot defaults load so HUD and live-adapter child processes cannot target another device.
42
+ - Resume broker commands on the next Hermes session generation without losing the pinned logical device.
43
+ - Keep Hermes session recovery inside the caller's RPC deadline, pass only the remaining time to the recovered command, and classify broker connection or request timeouts so transient HUD updates do not abort a recipe.
44
+ - Accept React Native trace response chunks up to 16 MiB while keeping broker requests capped at 8 MiB, and never retry the non-idempotent `Tracing.end` command.
45
+ - Limit Mobile UI captures to clock and native-frame categories; keep browser timeline and JavaScript task categories on Extension.
46
+
5
47
  ## 0.42.0 - 2026-08-20
6
48
 
7
49
  ### Added
package/README.md CHANGED
@@ -116,6 +116,11 @@ explicit `app.network_capture` and `app.network_assert` nodes for filtered
116
116
  windows and machine-checked request expectations. See
117
117
  [Recipe-scoped network capture](docs/NETWORK-CAPTURE.md).
118
118
 
119
+ Mobile and Extension recipes can bracket explicit UI-smoothness windows with
120
+ `app.performance_capture` and verify their evidence with
121
+ `app.performance_assert`. Capture is never automatic. See
122
+ [UI smoothness capture](docs/PERFORMANCE-CAPTURE.md).
123
+
119
124
  ## Discover and prove
120
125
 
121
126
  ```bash
@@ -18,10 +18,10 @@
18
18
 
19
19
  const fs = require('node:fs');
20
20
  const path = require('node:path');
21
+ const { execFileSync } = require('node:child_process');
21
22
  const { loadPort } = require('./lib/config.cjs');
22
23
  const {
23
24
  discoverTarget,
24
- rankRuntimeCandidates,
25
25
  } = require('./lib/target-discovery.cjs');
26
26
  const { createWSClient } = require('./lib/ws-client.cjs');
27
27
  const {
@@ -37,6 +37,126 @@ const {
37
37
  } = require('./lib/bridge-errors.cjs');
38
38
  const { cdpEval, cdpEvalAsync } = require('./lib/cdp-eval.cjs');
39
39
  const { buildArmSnippet, buildCollectSnippet } = require('./lib/issue-capture.cjs');
40
+ const APPLY_HUD_UPDATE_FUNCTION = `function(step) {
41
+ const bridge = this.__AGENTIC__;
42
+ if (step === null) {
43
+ if (typeof bridge?.hideStep !== 'function') return false;
44
+ setTimeout(() => bridge.hideStep(), 0);
45
+ return true;
46
+ }
47
+ if (typeof bridge?.showStep !== 'function') return false;
48
+ setTimeout(() => bridge.showStep(step), 0);
49
+ return true;
50
+ }`;
51
+
52
+ function mobileToolPath(tool) {
53
+ return process.env[`MM_HARNESS_${tool.toUpperCase()}_PATH`] || tool;
54
+ }
55
+
56
+ function bootedIosDevice(deviceName) {
57
+ if (!deviceName) return null;
58
+ try {
59
+ const devices = JSON.parse(
60
+ execFileSync('xcrun', ['simctl', 'list', 'devices', 'available', '-j'], {
61
+ encoding: 'utf8',
62
+ }),
63
+ );
64
+ const device = Object.values(devices.devices || {})
65
+ .flat()
66
+ .find(
67
+ (candidate) =>
68
+ (candidate?.name === deviceName || candidate?.udid === deviceName) &&
69
+ candidate?.state === 'Booted',
70
+ );
71
+ return device?.udid ? device : null;
72
+ } catch {
73
+ return null;
74
+ }
75
+ }
76
+
77
+ function tapVisibleIosAccessibilityTarget(testId, deviceName, platform) {
78
+ if (platform !== 'ios') return null;
79
+ const device = bootedIosDevice(deviceName);
80
+ if (!device) return null;
81
+ try {
82
+ const idb = mobileToolPath('idb');
83
+ const elements = JSON.parse(
84
+ execFileSync(
85
+ idb,
86
+ ['ui', 'describe-all', '--udid', device.udid, '--json'],
87
+ { encoding: 'utf8' },
88
+ ),
89
+ );
90
+ const windowFrame = elements
91
+ .map((element) => element?.frame)
92
+ .filter(
93
+ (frame) =>
94
+ frame &&
95
+ frame.x === 0 &&
96
+ frame.y === 0 &&
97
+ frame.width > 0 &&
98
+ frame.height > 0,
99
+ )
100
+ .sort(
101
+ (first, second) =>
102
+ second.width * second.height - first.width * first.height,
103
+ )[0];
104
+ if (!windowFrame) return null;
105
+ const target = elements.find((element) => {
106
+ const frame = element?.frame;
107
+ return (
108
+ element?.AXUniqueId === testId &&
109
+ element?.enabled !== false &&
110
+ element?.hittable !== false &&
111
+ frame &&
112
+ frame.width > 0 &&
113
+ frame.height > 0 &&
114
+ frame.x >= 0 &&
115
+ frame.y >= 0 &&
116
+ frame.x + frame.width <= windowFrame.width &&
117
+ frame.y + frame.height <= windowFrame.height
118
+ );
119
+ });
120
+ if (!target) return null;
121
+ const x = Math.round(target.frame.x + target.frame.width / 2);
122
+ const y = Math.round(target.frame.y + target.frame.height / 2);
123
+ execFileSync(
124
+ idb,
125
+ ['ui', 'tap', String(x), String(y), '--udid', device.udid],
126
+ { encoding: 'utf8' },
127
+ );
128
+ return { ok: true, testId, deviceName, provider: 'idb-accessibility' };
129
+ } catch {
130
+ return null;
131
+ }
132
+ }
133
+
134
+ async function applyHudUpdate(client, step) {
135
+ const globalObject = await client.send('Runtime.evaluate', {
136
+ expression: 'globalThis',
137
+ returnByValue: false,
138
+ awaitPromise: false,
139
+ });
140
+ const objectId = globalObject?.result?.objectId;
141
+ if (!objectId) {
142
+ throw new Error('Mobile HUD runtime did not expose globalThis');
143
+ }
144
+ const result = await client.send('Runtime.callFunctionOn', {
145
+ objectId,
146
+ functionDeclaration: APPLY_HUD_UPDATE_FUNCTION,
147
+ arguments: [{ value: step }],
148
+ returnByValue: true,
149
+ awaitPromise: false,
150
+ });
151
+ if (result?.exceptionDetails) {
152
+ throw new Error(
153
+ result.exceptionDetails.exception?.description ||
154
+ result.exceptionDetails.text ||
155
+ 'Mobile HUD update failed',
156
+ );
157
+ }
158
+ return result?.result?.value === true;
159
+ }
40
160
 
41
161
  function parsePerformanceConsoleEvent(params) {
42
162
  const markers = ['[PerpsPerf] ', '[HomepagePerf] '];
@@ -98,7 +218,7 @@ async function setInput(client, testId, value, { deviceName } = {}, redact = fal
98
218
  throw new Error('Usage: set-input <testId> <value>');
99
219
  }
100
220
  const expr = `(function() {
101
- if (globalThis.__AGENTIC__?.setInput) return globalThis.__AGENTIC__.setInput(${JSON.stringify(testId)}, ${JSON.stringify(value)});
221
+ if (globalThis.__AGENTIC__?.setInput) return Promise.resolve(globalThis.__AGENTIC__.setInput(${JSON.stringify(testId)}, ${JSON.stringify(value)}));
102
222
  var hook = globalThis.__REACT_DEVTOOLS_GLOBAL_HOOK__;
103
223
  if (!hook) return { ok: false, error: 'No React DevTools hook' };
104
224
  var renderers = hook.renderers;
@@ -134,7 +254,7 @@ async function setInput(client, testId, value, { deviceName } = {}, redact = fal
134
254
  })()`;
135
255
  let result;
136
256
  try {
137
- result = await cdpEval(client, expr);
257
+ result = await cdpEvalAsync(client, expr);
138
258
  } catch (error) {
139
259
  if (redact) {
140
260
  throw new Error(`Secret input could not be applied for testID ${testId}.`);
@@ -152,6 +272,26 @@ async function setInput(client, testId, value, { deviceName } = {}, redact = fal
152
272
  };
153
273
  }
154
274
 
275
+ function parseHudStep(args) {
276
+ const raw = args.join(' ');
277
+ let step;
278
+ try {
279
+ step = JSON.parse(raw);
280
+ } catch (error) {
281
+ throw new Error(`show-step-json requires a JSON step payload: ${error.message}`);
282
+ }
283
+ if (!step || typeof step !== 'object' || Array.isArray(step)) {
284
+ throw new Error('show-step-json requires a JSON object step payload');
285
+ }
286
+ if (typeof step.intent !== 'string' || !step.intent.trim()) {
287
+ throw new Error('show-step-json requires step.intent');
288
+ }
289
+ if (step.id !== undefined && typeof step.id !== 'string') {
290
+ throw new Error('show-step-json step.id must be a string when provided');
291
+ }
292
+ return step;
293
+ }
294
+
155
295
  const COMMANDS = {
156
296
  async navigate(client, args, { deviceName, platform } = {}) {
157
297
  const routeName = ROUTE_ALIASES[args[0]] || args[0];
@@ -301,14 +441,15 @@ const COMMANDS = {
301
441
  return await cdpEval(client, `globalThis.__AGENTIC__?.switchAccount(${JSON.stringify(address)})`);
302
442
  },
303
443
 
304
- async 'press-test-id'(client, args, { deviceName } = {}) {
444
+ async 'press-test-id'(client, args, { deviceName, platform } = {}) {
305
445
  const testId = args[0];
306
446
  if (!testId) {
307
447
  throw new Error('Usage: press-test-id <testId>');
308
448
  }
309
- // Try __AGENTIC__ bridge first, fall back to inline fiber walking
449
+ // Try the app bridge first. Native accessibility remains an iOS fallback
450
+ // for controls whose React handler cannot be invoked directly.
310
451
  const expr = `(function() {
311
- if (globalThis.__AGENTIC__?.pressTestId) return globalThis.__AGENTIC__.pressTestId(${JSON.stringify(testId)});
452
+ if (globalThis.__AGENTIC__?.pressTestId) return Promise.resolve(globalThis.__AGENTIC__.pressTestId(${JSON.stringify(testId)}));
312
453
  var hook = globalThis.__REACT_DEVTOOLS_GLOBAL_HOOK__;
313
454
  if (!hook) return { ok: false, error: 'No React DevTools hook' };
314
455
  var renderers = hook.renderers;
@@ -331,10 +472,72 @@ const COMMANDS = {
331
472
  }
332
473
  return { ok: false, error: 'No component with testID=' + ${JSON.stringify(testId)} + ' found or no onPress' };
333
474
  })()`;
334
- const result = await cdpEval(client, expr);
475
+ let result;
476
+ let bridgeError;
477
+ try {
478
+ result = await cdpEvalAsync(client, expr);
479
+ if (result?.ok !== false) {
480
+ return { ...result, testId, deviceName };
481
+ }
482
+ } catch (error) {
483
+ bridgeError = error;
484
+ }
485
+ const nativeResult = tapVisibleIosAccessibilityTarget(
486
+ testId,
487
+ deviceName,
488
+ platform,
489
+ );
490
+ if (nativeResult) return nativeResult;
491
+ if (bridgeError) throw bridgeError;
335
492
  return { ...result, testId, deviceName };
336
493
  },
337
494
 
495
+ async 'key-press'(_client, args, { deviceName, platform } = {}) {
496
+ const requestedKey = String(args[0] || 'Enter').toLowerCase();
497
+ const isEnter = requestedKey === 'enter' || requestedKey === 'return';
498
+ const isBack = requestedKey === 'escape' || requestedKey === 'back';
499
+ if (!isEnter && !isBack) {
500
+ throw new Error(`Unsupported Mobile key ${JSON.stringify(args[0])}`);
501
+ }
502
+ if (platform === 'android') {
503
+ const serial = process.env.ADB_SERIAL || process.env.ANDROID_SERIAL;
504
+ if (!serial) throw new Error('key-press requires a selected Android device');
505
+ const keyCode = isEnter ? '66' : '4';
506
+ execFileSync(
507
+ mobileToolPath('adb'),
508
+ ['-s', serial, 'shell', 'input', 'keyevent', keyCode],
509
+ { encoding: 'utf8' },
510
+ );
511
+ return {
512
+ ok: true,
513
+ key: args[0] || 'Enter',
514
+ keyCode,
515
+ deviceName,
516
+ provider: 'adb-key',
517
+ };
518
+ }
519
+ if (platform !== 'ios') {
520
+ throw new Error(`key-press requires a selected Mobile platform`);
521
+ }
522
+ const device = bootedIosDevice(deviceName);
523
+ if (!device) {
524
+ throw new Error(`No booted simulator named ${JSON.stringify(deviceName)}`);
525
+ }
526
+ const keyCode = isEnter ? '40' : '41';
527
+ execFileSync(
528
+ mobileToolPath('idb'),
529
+ ['ui', 'key', keyCode, '--udid', device.udid],
530
+ { encoding: 'utf8' },
531
+ );
532
+ return {
533
+ ok: true,
534
+ key: args[0] || 'Enter',
535
+ keyCode,
536
+ deviceName,
537
+ provider: 'idb-key',
538
+ };
539
+ },
540
+
338
541
  async 'long-press-test-id'(client, args, { deviceName } = {}) {
339
542
  const testId = args[0];
340
543
  if (!testId) {
@@ -407,6 +610,9 @@ const COMMANDS = {
407
610
  // selector fiber. For into-view requests the scroll container is commonly
408
611
  // an ancestor (for example, a Wallet Home section row), so use the
409
612
  // ancestor-aware bridge fallback below instead.
613
+ if (${intoView} && globalThis.__AGENTIC__?.scrollIntoView) {
614
+ return globalThis.__AGENTIC__.scrollIntoView(${JSON.stringify(testId)}, ${animated});
615
+ }
410
616
  if (!${intoView} && globalThis.__AGENTIC__?.scrollView) return globalThis.__AGENTIC__.scrollView(${optsJson});
411
617
  var hook = globalThis.__REACT_DEVTOOLS_GLOBAL_HOOK__;
412
618
  if (!hook) return { ok: false, error: 'No React DevTools hook' };
@@ -428,14 +634,56 @@ const COMMANDS = {
428
634
  }
429
635
  return false;
430
636
  }
637
+ function findMeasurable(fiber) {
638
+ if (!fiber) return null;
639
+ var sn = fiber.stateNode;
640
+ if (sn && typeof sn.measureLayout === 'function') return sn;
641
+ return findMeasurable(fiber.child);
642
+ }
431
643
  function tryScrollNear(anchor) {
432
644
  if (!opts.intoView && tryScroll(anchor, false)) return true;
645
+ var target = opts.intoView ? findMeasurable(anchor) : null;
433
646
  var current = anchor ? anchor.return : null;
434
647
  while (current) {
435
648
  var sn = current.stateNode;
649
+ var currentProps = current.memoizedProps;
436
650
  if (sn) {
437
- if (typeof sn.scrollTo === 'function') { sn.scrollTo({ y: opts.offset, animated: opts.animated }); return true; }
438
- if (typeof sn.scrollToOffset === 'function') { sn.scrollToOffset({ offset: opts.offset, animated: opts.animated }); return true; }
651
+ if (typeof sn.scrollTo === 'function' && !(currentProps && currentProps.horizontal === true)) {
652
+ if (target) {
653
+ return new Promise(function(resolve) {
654
+ target.measureLayout(
655
+ sn,
656
+ function(x, y) {
657
+ sn.scrollTo({ y: y, animated: opts.animated });
658
+ resolve({ ok: true, testId: opts.testId, measuredOffset: y, animated: opts.animated });
659
+ },
660
+ function() {
661
+ resolve({ ok: false, error: 'Unable to measure testID=' + opts.testId + ' relative to its scroll container' });
662
+ }
663
+ );
664
+ });
665
+ }
666
+ sn.scrollTo({ y: opts.offset, animated: opts.animated });
667
+ return true;
668
+ }
669
+ if (typeof sn.scrollToOffset === 'function' && !(currentProps && currentProps.horizontal === true)) {
670
+ if (target) {
671
+ return new Promise(function(resolve) {
672
+ target.measureLayout(
673
+ sn,
674
+ function(x, y) {
675
+ sn.scrollToOffset({ offset: y, animated: opts.animated });
676
+ resolve({ ok: true, testId: opts.testId, measuredOffset: y, animated: opts.animated });
677
+ },
678
+ function() {
679
+ resolve({ ok: false, error: 'Unable to measure testID=' + opts.testId + ' relative to its scroll container' });
680
+ }
681
+ );
682
+ });
683
+ }
684
+ sn.scrollToOffset({ offset: opts.offset, animated: opts.animated });
685
+ return true;
686
+ }
439
687
  }
440
688
  current = current.return;
441
689
  }
@@ -444,7 +692,23 @@ const COMMANDS = {
444
692
  function findTestId(fiber) {
445
693
  if (!fiber) return null;
446
694
  var props = fiber.memoizedProps;
447
- if (props && props.testID === opts.testId) return fiber;
695
+ if (props && props.testID === opts.testId) {
696
+ var ancestor = fiber;
697
+ var inactive = false;
698
+ while (ancestor) {
699
+ var ancestorProps = ancestor.memoizedProps;
700
+ var ancestorStyle = ancestorProps && ancestorProps.style;
701
+ if (
702
+ (ancestorProps && ancestorProps.activityState === 0) ||
703
+ (ancestorStyle && ancestorStyle.display === 'none')
704
+ ) {
705
+ inactive = true;
706
+ break;
707
+ }
708
+ ancestor = ancestor.return;
709
+ }
710
+ if (!inactive) return fiber;
711
+ }
448
712
  return findTestId(fiber.child) || findTestId(fiber.sibling);
449
713
  }
450
714
  for (var [id] of renderers) {
@@ -460,11 +724,16 @@ const COMMANDS = {
460
724
  scrolled = tryScroll(r.current);
461
725
  }
462
726
  });
463
- if (scrolled) return { ok: true, testId: opts.testId, offset: opts.offset, animated: opts.animated };
727
+ if (scrolled) {
728
+ if (typeof scrolled.then === 'function') return scrolled;
729
+ return { ok: true, testId: opts.testId, offset: opts.offset, animated: opts.animated };
730
+ }
464
731
  }
465
732
  return { ok: false, error: opts.testId ? 'No scrollable near testID=' + opts.testId : 'No scrollable found' };
466
733
  })()`;
467
- const result = await cdpEval(client, expr);
734
+ const result = intoView
735
+ ? await cdpEvalAsync(client, expr)
736
+ : await cdpEval(client, expr);
468
737
  return { ...result, deviceName };
469
738
  },
470
739
 
@@ -842,34 +1111,33 @@ const COMMANDS = {
842
1111
  },
843
1112
 
844
1113
  async 'show-step-json'(client, args) {
845
- const raw = args.join(' ');
846
- let step;
847
- try {
848
- step = JSON.parse(raw);
849
- } catch (error) {
850
- throw new Error(`show-step-json requires a JSON step payload: ${error.message}`);
851
- }
852
- if (!step || typeof step !== 'object' || Array.isArray(step)) {
853
- throw new Error('show-step-json requires a JSON object step payload');
854
- }
855
- if (typeof step.intent !== 'string' || !step.intent.trim()) {
856
- throw new Error('show-step-json requires step.intent');
857
- }
858
- // HUD derives status/progress from step.id when status is omitted; a
859
- // non-string id makes statusForStep throw during render. Reject at the boundary.
860
- if (step.id !== undefined && typeof step.id !== 'string') {
861
- throw new Error('show-step-json step.id must be a string when provided');
862
- }
863
- const payload = JSON.stringify(step);
864
- await cdpEval(client, `globalThis.__AGENTIC__?.showStep && globalThis.__AGENTIC__.showStep(${payload})`);
1114
+ const step = parseHudStep(args);
1115
+ await applyHudUpdate(client, step);
865
1116
  return { ok: true };
866
1117
  },
867
1118
 
1119
+ async 'show-step-json-deferred'(client, args) {
1120
+ const step = parseHudStep(args);
1121
+ if (typeof client.control === 'function') {
1122
+ return client.control('hud-update', { step });
1123
+ }
1124
+ await applyHudUpdate(client, step);
1125
+ return { ok: true, status: 'applied' };
1126
+ },
1127
+
868
1128
  async 'hide-step'(client) {
869
- await cdpEval(client, `globalThis.__AGENTIC__?.hideStep && globalThis.__AGENTIC__.hideStep()`);
1129
+ await applyHudUpdate(client, null);
870
1130
  return { ok: true };
871
1131
  },
872
1132
 
1133
+ async 'hide-step-deferred'(client) {
1134
+ if (typeof client.control === 'function') {
1135
+ return client.control('hud-update', { step: null });
1136
+ }
1137
+ await applyHudUpdate(client, null);
1138
+ return { ok: true, status: 'applied' };
1139
+ },
1140
+
873
1141
  async 'profiler-start'(client) {
874
1142
  // Hermes CDP exposes the sampling profiler via the Profiler domain.
875
1143
  // Output of Profiler.stop is a Chrome-compatible .cpuprofile object.
@@ -961,7 +1229,6 @@ const BRIDGE_LOCK_FILE = path.join(
961
1229
  process.env.RECIPE_RUNTIME_DIR || path.join('temp', 'recipe', 'runtime'),
962
1230
  'cdp-bridge.lock',
963
1231
  );
964
- const BROKER_SOCKET_FILE = brokerSocketPath(path.dirname(BRIDGE_LOCK_FILE));
965
1232
  const BRIDGE_LOCK_OWNER_PID = /^\d+$/.test(
966
1233
  process.env.CDP_BRIDGE_LOCK_OWNER_PID || '',
967
1234
  )
@@ -1063,30 +1330,70 @@ Environment:
1063
1330
  }
1064
1331
 
1065
1332
  const port = loadPort();
1333
+ const brokerSocketFile = brokerSocketPath(
1334
+ path.dirname(BRIDGE_LOCK_FILE),
1335
+ port,
1336
+ );
1066
1337
  const timeout = Number.parseInt(process.env.CDP_TIMEOUT || '5000', 10);
1067
- const brokerAvailable = fs.existsSync(BROKER_SOCKET_FILE);
1338
+ const brokerAvailable = fs.existsSync(brokerSocketFile);
1068
1339
  if (!brokerAvailable) acquireBridgeLock();
1069
1340
 
1070
- async function brokerTargets() {
1071
- const response = await fetch(`http://127.0.0.1:${port}/json/list`);
1072
- if (!response.ok) throw new Error(`Metro target listing returned ${response.status}`);
1073
- const ranked = rankRuntimeCandidates(await response.json());
1074
- const picked = new Set();
1075
- return ranked.flatMap((target) => {
1076
- const deviceId = deviceIdFromUrl(target.webSocketDebuggerUrl);
1077
- if (picked.has(deviceId)) return [];
1078
- picked.add(deviceId);
1079
- return [{
1080
- wsUrl: target.webSocketDebuggerUrl,
1081
- deviceName: target.deviceName || '',
1082
- }];
1083
- });
1341
+ function selectedTargetPin() {
1342
+ const platform = String(process.env.MM_HARNESS_EXPLICIT_PLATFORM || '').toLowerCase();
1343
+ const androidPin =
1344
+ process.env.ANDROID_TARGET_DEVICE_NAME || process.env.ANDROID_DEVICE || '';
1345
+ const iosPin = process.env.IOS_SIMULATOR || '';
1346
+ return String(
1347
+ platform === 'android'
1348
+ ? androidPin
1349
+ : platform === 'ios'
1350
+ ? iosPin
1351
+ : androidPin || iosPin,
1352
+ ).trim();
1353
+ }
1354
+
1355
+ async function brokerTargets({ retainIdentity = false } = {}) {
1356
+ const discoveryClient = await createBrokerClient(
1357
+ brokerSocketFile,
1358
+ '',
1359
+ timeout,
1360
+ );
1361
+ try {
1362
+ const targets = await discoveryClient.control(
1363
+ retainIdentity ? 'resolve-targets' : 'list-targets',
1364
+ retainIdentity ? { nameIncludes: selectedTargetPin() } : {},
1365
+ timeout,
1366
+ );
1367
+ return (Array.isArray(targets) ? targets : []).flatMap((target) => {
1368
+ const deviceId = String(target?.deviceId || '');
1369
+ return deviceId
1370
+ ? [{ wsUrl: deviceId, deviceName: String(target?.name || '') }]
1371
+ : [];
1372
+ });
1373
+ } finally {
1374
+ discoveryClient.close();
1375
+ }
1376
+ }
1377
+
1378
+ function selectBrokerTarget(targets) {
1379
+ const pin = selectedTargetPin();
1380
+ const candidates = pin
1381
+ ? targets.filter((target) =>
1382
+ target.deviceName.toLowerCase().includes(pin.toLowerCase()),
1383
+ )
1384
+ : targets;
1385
+ if (candidates.length !== 1) {
1386
+ throw new Error(
1387
+ `Mobile CDP broker target selection requires one target; found ${candidates.length}${pin ? ` for ${JSON.stringify(pin)}` : ''}.`,
1388
+ );
1389
+ }
1390
+ return candidates[0];
1084
1391
  }
1085
1392
 
1086
1393
  async function clientFor(wsUrl) {
1087
1394
  if (brokerAvailable) {
1088
1395
  return createBrokerClient(
1089
- BROKER_SOCKET_FILE,
1396
+ brokerSocketFile,
1090
1397
  deviceIdFromUrl(wsUrl),
1091
1398
  timeout,
1092
1399
  );
@@ -1125,15 +1432,16 @@ Environment:
1125
1432
  return;
1126
1433
  }
1127
1434
 
1128
- const { wsUrl, deviceName } = await discoverTarget(port, {
1129
- probe: !brokerAvailable,
1130
- });
1435
+ const { wsUrl, deviceName } = brokerAvailable
1436
+ ? selectBrokerTarget(await brokerTargets({ retainIdentity: true }))
1437
+ : await discoverTarget(port, { probe: true });
1131
1438
  if (!wsUrl) throw new Error('No broker-owned Hermes target is available');
1132
1439
  const client = await clientFor(wsUrl);
1133
1440
 
1134
1441
  try {
1135
- // Detect platform from the running app (exposed by __AGENTIC__ bridge as Platform.OS)
1136
- const platform = await cdpEval(client, 'globalThis.__AGENTIC__?.platform') || '';
1442
+ const platform = command.endsWith('-deferred')
1443
+ ? ''
1444
+ : await cdpEval(client, 'globalThis.__AGENTIC__?.platform') || '';
1137
1445
  const result = await handler(client, args.slice(1), { deviceName, platform });
1138
1446
  console.log(JSON.stringify(result, null, 2));
1139
1447
  } finally {