@deeeed/metamask-harness 0.42.0 → 0.43.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 (30) hide show
  1. package/CHANGELOG.md +17 -0
  2. package/README.md +5 -0
  3. package/adapters/mobile/bridge-runtime/cdp-bridge.cjs +59 -21
  4. package/adapters/mobile/bridge-runtime/console-forwarder.cjs +3 -2
  5. package/adapters/mobile/bridge-runtime/lib/bridge-errors.cjs +2 -0
  6. package/adapters/mobile/bridge-runtime/lib/cdp-broker.cjs +194 -25
  7. package/adapters/mobile/bridge-runtime/lib/config.cjs +14 -4
  8. package/adapters/mobile/start-console-forwarder.sh +5 -1
  9. package/dist/adapters/extension/browser-cdp.js +174 -0
  10. package/dist/adapters/extension/network-observer.js +19 -110
  11. package/dist/adapters/extension/performance-observer.js +75 -0
  12. package/dist/adapters/mobile/frame-metrics.js +45 -0
  13. package/dist/adapters/mobile/performance-observer.js +43 -0
  14. package/dist/adapters/performance/cdp-trace.js +342 -0
  15. package/dist/adapters/performance/js-task-metrics.js +35 -0
  16. package/dist/adapters.js +17 -2
  17. package/dist/artifact-files.js +92 -0
  18. package/dist/async.js +19 -0
  19. package/dist/commands/call.js +23 -2
  20. package/dist/commands/run-engine.js +18 -0
  21. package/dist/commands/run.js +20 -1
  22. package/dist/network-observation.js +59 -47
  23. package/dist/performance-observation.js +465 -0
  24. package/docs/PERFORMANCE-CAPTURE.md +33 -0
  25. package/docs/RECIPES.md +7 -0
  26. package/library/actions/mobile/platform/bridge.mjs +3 -0
  27. package/library/manifests/extension.action-manifest.json +85 -0
  28. package/library/manifests/mobile.action-manifest.json +97 -0
  29. package/package.json +1 -1
  30. package/scripts/site-contrast.mjs +43 -27
package/CHANGELOG.md CHANGED
@@ -2,6 +2,23 @@
2
2
 
3
3
  ## Unreleased
4
4
 
5
+ ## 0.43.0 - 2026-08-21
6
+
7
+ ### Added
8
+
9
+ - 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.
10
+ - 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.
11
+
12
+ ### Fixed
13
+
14
+ - 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.
15
+ - 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.
16
+ - Preserve explicit Mobile `--device` pins when slot defaults load so HUD and live-adapter child processes cannot target another device.
17
+ - Resume broker commands on the next Hermes session generation without losing the pinned logical device.
18
+ - 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.
19
+ - 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.
20
+ - Limit Mobile UI captures to clock and native-frame categories; keep browser timeline and JavaScript task categories on Extension.
21
+
5
22
  ## 0.42.0 - 2026-08-20
6
23
 
7
24
  ### 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
@@ -21,7 +21,6 @@ const path = require('node:path');
21
21
  const { loadPort } = require('./lib/config.cjs');
22
22
  const {
23
23
  discoverTarget,
24
- rankRuntimeCandidates,
25
24
  } = require('./lib/target-discovery.cjs');
26
25
  const { createWSClient } = require('./lib/ws-client.cjs');
27
26
  const {
@@ -961,7 +960,6 @@ const BRIDGE_LOCK_FILE = path.join(
961
960
  process.env.RECIPE_RUNTIME_DIR || path.join('temp', 'recipe', 'runtime'),
962
961
  'cdp-bridge.lock',
963
962
  );
964
- const BROKER_SOCKET_FILE = brokerSocketPath(path.dirname(BRIDGE_LOCK_FILE));
965
963
  const BRIDGE_LOCK_OWNER_PID = /^\d+$/.test(
966
964
  process.env.CDP_BRIDGE_LOCK_OWNER_PID || '',
967
965
  )
@@ -1063,30 +1061,70 @@ Environment:
1063
1061
  }
1064
1062
 
1065
1063
  const port = loadPort();
1064
+ const brokerSocketFile = brokerSocketPath(
1065
+ path.dirname(BRIDGE_LOCK_FILE),
1066
+ port,
1067
+ );
1066
1068
  const timeout = Number.parseInt(process.env.CDP_TIMEOUT || '5000', 10);
1067
- const brokerAvailable = fs.existsSync(BROKER_SOCKET_FILE);
1069
+ const brokerAvailable = fs.existsSync(brokerSocketFile);
1068
1070
  if (!brokerAvailable) acquireBridgeLock();
1069
1071
 
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
- });
1072
+ function selectedTargetPin() {
1073
+ const platform = String(process.env.MM_HARNESS_EXPLICIT_PLATFORM || '').toLowerCase();
1074
+ const androidPin =
1075
+ process.env.ANDROID_TARGET_DEVICE_NAME || process.env.ANDROID_DEVICE || '';
1076
+ const iosPin = process.env.IOS_SIMULATOR || '';
1077
+ return String(
1078
+ platform === 'android'
1079
+ ? androidPin
1080
+ : platform === 'ios'
1081
+ ? iosPin
1082
+ : androidPin || iosPin,
1083
+ ).trim();
1084
+ }
1085
+
1086
+ async function brokerTargets({ retainIdentity = false } = {}) {
1087
+ const discoveryClient = await createBrokerClient(
1088
+ brokerSocketFile,
1089
+ '',
1090
+ timeout,
1091
+ );
1092
+ try {
1093
+ const targets = await discoveryClient.control(
1094
+ retainIdentity ? 'resolve-targets' : 'list-targets',
1095
+ retainIdentity ? { nameIncludes: selectedTargetPin() } : {},
1096
+ timeout,
1097
+ );
1098
+ return (Array.isArray(targets) ? targets : []).flatMap((target) => {
1099
+ const deviceId = String(target?.deviceId || '');
1100
+ return deviceId
1101
+ ? [{ wsUrl: deviceId, deviceName: String(target?.name || '') }]
1102
+ : [];
1103
+ });
1104
+ } finally {
1105
+ discoveryClient.close();
1106
+ }
1107
+ }
1108
+
1109
+ function selectBrokerTarget(targets) {
1110
+ const pin = selectedTargetPin();
1111
+ const candidates = pin
1112
+ ? targets.filter((target) =>
1113
+ target.deviceName.toLowerCase().includes(pin.toLowerCase()),
1114
+ )
1115
+ : targets;
1116
+ if (candidates.length !== 1) {
1117
+ throw new Error(
1118
+ `Mobile CDP broker target selection requires one target; found ${candidates.length}${pin ? ` for ${JSON.stringify(pin)}` : ''}.`,
1119
+ );
1120
+ }
1121
+ return candidates[0];
1084
1122
  }
1085
1123
 
1086
1124
  async function clientFor(wsUrl) {
1087
1125
  if (brokerAvailable) {
1088
1126
  return createBrokerClient(
1089
- BROKER_SOCKET_FILE,
1127
+ brokerSocketFile,
1090
1128
  deviceIdFromUrl(wsUrl),
1091
1129
  timeout,
1092
1130
  );
@@ -1125,9 +1163,9 @@ Environment:
1125
1163
  return;
1126
1164
  }
1127
1165
 
1128
- const { wsUrl, deviceName } = await discoverTarget(port, {
1129
- probe: !brokerAvailable,
1130
- });
1166
+ const { wsUrl, deviceName } = brokerAvailable
1167
+ ? selectBrokerTarget(await brokerTargets({ retainIdentity: true }))
1168
+ : await discoverTarget(port, { probe: true });
1131
1169
  if (!wsUrl) throw new Error('No broker-owned Hermes target is available');
1132
1170
  const client = await clientFor(wsUrl);
1133
1171
 
@@ -35,6 +35,7 @@ const {
35
35
  deviceIdFromUrl,
36
36
  } = require('./lib/cdp-broker.cjs');
37
37
  const { createDevtoolsProxy } = require('./lib/devtools-proxy.cjs');
38
+ const { resolvePort } = require('./lib/config.cjs');
38
39
 
39
40
  // Hermes uses Node's built-in WebSocket client. The local DevTools proxy uses
40
41
  // the package's pinned `ws` server implementation.
@@ -46,7 +47,7 @@ const FLUSH_MS = 100;
46
47
  const MAX_LINE_CHARS = 4000;
47
48
 
48
49
  function parseArgs(argv) {
49
- const args = { port: process.env.WATCHER_PORT || '8081', out: null };
50
+ const args = { port: resolvePort(), out: null };
50
51
  for (let i = 2; i < argv.length; i += 1) {
51
52
  if (argv[i] === '--port') args.port = argv[++i];
52
53
  else if (argv[i] === '--out') args.out = argv[++i];
@@ -208,7 +209,7 @@ function sendCommand(session, method, params = {}, timeoutMs = 10_000) {
208
209
  }
209
210
 
210
211
  broker = createCdpBroker({
211
- socketPath: brokerSocketPath(path.dirname(out)),
212
+ socketPath: brokerSocketPath(path.dirname(out), port),
212
213
  sessions,
213
214
  sendCommand,
214
215
  requestDiscovery(deviceId) {
@@ -63,6 +63,8 @@ const NEEDLE_CODES = [
63
63
  ['websocket error', BRIDGE_ERROR_CODES.WS_CLOSED],
64
64
  ['cdp connection timeout', BRIDGE_ERROR_CODES.CDP_TIMEOUT],
65
65
  ['cdp message timeout', BRIDGE_ERROR_CODES.CDP_TIMEOUT],
66
+ ['cdp broker connection timeout', BRIDGE_ERROR_CODES.CDP_TIMEOUT],
67
+ ['cdp broker request timeout', BRIDGE_ERROR_CODES.CDP_TIMEOUT],
66
68
  ['evaluation timed out', BRIDGE_ERROR_CODES.CDP_TIMEOUT],
67
69
  ['timed out', BRIDGE_ERROR_CODES.CDP_TIMEOUT],
68
70
  ];
@@ -12,7 +12,8 @@ const MAX_ACTIVE_CAPTURES = 16;
12
12
  const MAX_COMPLETED_CAPTURES = 16;
13
13
  const MAX_CAPTURE_BYTES = 4 * 1024 * 1024;
14
14
  const MAX_POST_DATA_BYTES = 64 * 1024;
15
- const MAX_RPC_FRAME_BYTES = 8 * 1024 * 1024;
15
+ const MAX_REQUEST_FRAME_BYTES = 8 * 1024 * 1024;
16
+ const MAX_RESPONSE_FRAME_BYTES = 16 * 1024 * 1024;
16
17
  const MAX_RETAINED_STRING_LENGTH = 256;
17
18
  const MAX_BROKER_TIMEOUT_MS = 60_000;
18
19
  const SENSITIVE_FIELD =
@@ -20,14 +21,18 @@ const SENSITIVE_FIELD =
20
21
  const SENSITIVE_VALUE =
21
22
  /(?:0x[a-f0-9]{40,}|[a-f0-9]{64,}|eyJ[a-z0-9_-]{20,}\.[a-z0-9_-]{20,})/iu;
22
23
 
23
- function brokerSocketPath(runtimeDir) {
24
+ function brokerSocketPath(runtimeDir, endpointIdentity) {
24
25
  const runtimePath = path.resolve(
25
26
  runtimeDir ||
26
27
  process.env.RECIPE_RUNTIME_DIR ||
27
28
  path.join('temp', 'recipe', 'runtime'),
28
29
  );
30
+ const socketIdentity =
31
+ endpointIdentity === undefined || endpointIdentity === null
32
+ ? runtimePath
33
+ : `${runtimePath}\u0000${String(endpointIdentity)}`;
29
34
  const identity = createHash('sha256')
30
- .update(runtimePath)
35
+ .update(socketIdentity)
31
36
  .digest('hex')
32
37
  .slice(0, 20);
33
38
  return path.join('/tmp', `mmh-cdp-${identity}.sock`);
@@ -77,6 +82,23 @@ function boundedTimeout(value, fallback = 10_000) {
77
82
  : fallback;
78
83
  }
79
84
 
85
+ // State recovery is event-driven. Pick a fixed bound below the caller's RPC
86
+ // deadline so the broker always answers before the client gives up. Returning
87
+ // literals also keeps the timer independent from untrusted request values.
88
+ function stateWaitTimeout(value) {
89
+ const timeout = boundedTimeout(value);
90
+ if (timeout <= 500) return 1;
91
+ if (timeout <= 1_000) return 400;
92
+ if (timeout <= 2_000) return 900;
93
+ if (timeout <= 5_000) return 1_900;
94
+ if (timeout <= 10_000) return 4_500;
95
+ if (timeout <= 15_000) return 9_500;
96
+ if (timeout <= 30_000) return 14_500;
97
+ if (timeout <= 45_000) return 29_500;
98
+ if (timeout <= 55_000) return 44_500;
99
+ return 54_500;
100
+ }
101
+
80
102
  function readField(value, field) {
81
103
  let current = value;
82
104
  for (const segment of String(field).split('.')) {
@@ -333,6 +355,25 @@ function createCdpBroker({
333
355
  const completedCaptures = new Map();
334
356
  const clients = new Set();
335
357
  const subscriptions = new Map();
358
+ const knownTargets = new Map(
359
+ [...sessions.entries()].flatMap(([deviceId, session]) =>
360
+ session?.brokerReady
361
+ ? [[deviceId, {
362
+ deviceId,
363
+ generation: 1,
364
+ name: String(session.name || ''),
365
+ ready: true,
366
+ }]]
367
+ : [],
368
+ ),
369
+ );
370
+ const sessionWaiters = new Map();
371
+ const targetWaiters = new Set();
372
+ const observedSessions = new Map(
373
+ [...sessions.entries()].flatMap(([deviceId, session]) =>
374
+ session?.brokerReady ? [[deviceId, session]] : [],
375
+ ),
376
+ );
336
377
 
337
378
  const capturesFor = (deviceId) => {
338
379
  if (!captures.has(deviceId)) captures.set(deviceId, new Map());
@@ -346,23 +387,83 @@ function createCdpBroker({
346
387
  return completedCaptures.get(deviceId);
347
388
  };
348
389
 
349
- async function waitForSession(deviceId, timeoutMs) {
390
+ function targetList({ nameIncludes = '', readyOnly = false } = {}) {
391
+ const pin = String(nameIncludes).trim().toLowerCase();
392
+ return [...knownTargets.values()]
393
+ .filter((target) => !readyOnly || target.ready)
394
+ .filter((target) => !pin || target.name.toLowerCase().includes(pin))
395
+ .map((target) => ({ ...target }))
396
+ .sort((a, b) => a.name.localeCompare(b.name));
397
+ }
398
+
399
+ function settleTargetWaiters() {
400
+ for (const waiter of [...targetWaiters]) {
401
+ const targets = targetList({ nameIncludes: waiter.nameIncludes });
402
+ if (targets.length === 0) continue;
403
+ targetWaiters.delete(waiter);
404
+ clearTimeout(waiter.timer);
405
+ waiter.resolve(targets);
406
+ }
407
+ }
408
+
409
+ function resolveTargets(params, timeoutMs, socket) {
410
+ const existing = targetList({ nameIncludes: params.nameIncludes });
411
+ if (existing.length > 0) return Promise.resolve(existing);
412
+ requestDiscovery?.('');
413
+ return new Promise((resolve, reject) => {
414
+ const waiter = {
415
+ nameIncludes: String(params.nameIncludes || ''),
416
+ resolve,
417
+ reject,
418
+ socket,
419
+ timer: null,
420
+ };
421
+ waiter.timer = setTimeout(() => {
422
+ targetWaiters.delete(waiter);
423
+ reject(new Error('CDP broker never observed the requested target'));
424
+ }, stateWaitTimeout(timeoutMs));
425
+ targetWaiters.add(waiter);
426
+ settleTargetWaiters();
427
+ });
428
+ }
429
+
430
+ function waitForSession(deviceId, timeoutMs, socket) {
350
431
  if (sessions.get(deviceId)?.brokerReady) return sessions.get(deviceId);
351
432
  requestDiscovery?.(deviceId);
352
- const deadline = Date.now() + boundedTimeout(timeoutMs);
353
- while (Date.now() < deadline) {
354
- await new Promise((resolve) => setTimeout(resolve, 100));
433
+ return new Promise((resolve, reject) => {
434
+ const waiters = sessionWaiters.get(deviceId) || new Set();
435
+ const waiter = {
436
+ resolve,
437
+ reject,
438
+ socket,
439
+ timer: setTimeout(() => {
440
+ waiters.delete(waiter);
441
+ if (waiters.size === 0) sessionWaiters.delete(deviceId);
442
+ reject(new Error(`CDP broker target unavailable: ${deviceId}`));
443
+ }, stateWaitTimeout(timeoutMs)),
444
+ };
445
+ waiters.add(waiter);
446
+ sessionWaiters.set(deviceId, waiters);
355
447
  const session = sessions.get(deviceId);
356
- if (session?.brokerReady) return session;
357
- }
358
- throw new Error('CDP broker target unavailable');
448
+ if (session?.brokerReady) {
449
+ waiters.delete(waiter);
450
+ if (waiters.size === 0) sessionWaiters.delete(deviceId);
451
+ clearTimeout(waiter.timer);
452
+ resolve(session);
453
+ }
454
+ });
359
455
  }
360
456
 
361
- async function enableNetwork(deviceId, capture, timeoutMs = 10_000) {
457
+ async function enableNetwork(
458
+ deviceId,
459
+ capture,
460
+ timeoutMs = 10_000,
461
+ socket,
462
+ ) {
362
463
  try {
363
464
  const budgetMs = boundedTimeout(timeoutMs);
364
465
  const deadline = Date.now() + budgetMs;
365
- const session = await waitForSession(deviceId, budgetMs);
466
+ const session = await waitForSession(deviceId, budgetMs, socket);
366
467
  const remainingMs = Math.max(1, deadline - Date.now());
367
468
  await sendCommand(session, 'Network.enable', {}, remainingMs);
368
469
  if (capture.enableErrors.length > 0) capture.partial = true;
@@ -422,15 +523,21 @@ function createCdpBroker({
422
523
  };
423
524
  }
424
525
 
425
- async function control(deviceId, action, params, timeoutMs = 10_000) {
526
+ async function control(
527
+ deviceId,
528
+ action,
529
+ params,
530
+ timeoutMs = 10_000,
531
+ socket,
532
+ ) {
426
533
  if (action === 'list-targets') {
427
- return [...sessions.entries()]
428
- .filter(([, session]) => session.brokerReady)
429
- .map(([activeDeviceId, session]) => ({
430
- deviceId: activeDeviceId,
431
- name: String(session.name || ''),
432
- }))
433
- .sort((a, b) => a.name.localeCompare(b.name));
534
+ return targetList({ readyOnly: true }).map(({ deviceId, name }) => ({
535
+ deviceId,
536
+ name,
537
+ }));
538
+ }
539
+ if (action === 'resolve-targets') {
540
+ return resolveTargets(params, timeoutMs, socket);
434
541
  }
435
542
  if (action === 'capture-start') {
436
543
  const capture = normalizeCapture(params);
@@ -443,7 +550,12 @@ function createCdpBroker({
443
550
  }
444
551
  completedCapturesFor(deviceId).delete(capture.id);
445
552
  deviceCaptures.set(capture.id, capture);
446
- await enableNetwork(deviceId, capture, Math.max(1, timeoutMs - 500));
553
+ await enableNetwork(
554
+ deviceId,
555
+ capture,
556
+ Math.max(1, timeoutMs - 500),
557
+ socket,
558
+ );
447
559
  return { id: capture.id, status: 'started' };
448
560
  }
449
561
  if (action === 'capture-end') {
@@ -467,15 +579,18 @@ function createCdpBroker({
467
579
  try {
468
580
  onClientActivity?.();
469
581
  if (request.type === 'command') {
582
+ const budgetMs = boundedTimeout(request.timeoutMs);
583
+ const deadline = Date.now() + budgetMs;
470
584
  const session = await waitForSession(
471
585
  request.deviceId,
472
- boundedTimeout(request.timeoutMs),
586
+ budgetMs,
587
+ socket,
473
588
  );
474
589
  const result = await sendCommand(
475
590
  session,
476
591
  request.method,
477
592
  request.params || {},
478
- boundedTimeout(request.timeoutMs),
593
+ Math.max(1, deadline - Date.now()),
479
594
  );
480
595
  writeMessage(socket, { id: request.id, result });
481
596
  return;
@@ -486,6 +601,7 @@ function createCdpBroker({
486
601
  request.action,
487
602
  request.params || {},
488
603
  boundedTimeout(request.timeoutMs),
604
+ socket,
489
605
  );
490
606
  writeMessage(socket, { id: request.id, result });
491
607
  return;
@@ -532,7 +648,7 @@ function createCdpBroker({
532
648
  socket.setEncoding('utf8');
533
649
  socket.on('data', (chunk) => {
534
650
  buffer += chunk;
535
- if (Buffer.byteLength(buffer) > MAX_RPC_FRAME_BYTES) {
651
+ if (Buffer.byteLength(buffer) > MAX_REQUEST_FRAME_BYTES) {
536
652
  writeMessage(socket, { error: 'CDP broker request exceeds its bound' });
537
653
  socket.destroy();
538
654
  return;
@@ -556,6 +672,21 @@ function createCdpBroker({
556
672
  const drop = () => {
557
673
  clients.delete(socket);
558
674
  subscriptions.delete(socket);
675
+ for (const waiter of [...targetWaiters]) {
676
+ if (waiter.socket !== socket) continue;
677
+ targetWaiters.delete(waiter);
678
+ clearTimeout(waiter.timer);
679
+ waiter.reject(new Error('CDP broker client closed'));
680
+ }
681
+ for (const [deviceId, waiters] of sessionWaiters) {
682
+ for (const waiter of [...waiters]) {
683
+ if (waiter.socket !== socket) continue;
684
+ waiters.delete(waiter);
685
+ clearTimeout(waiter.timer);
686
+ waiter.reject(new Error('CDP broker client closed'));
687
+ }
688
+ if (waiters.size === 0) sessionWaiters.delete(deviceId);
689
+ }
559
690
  };
560
691
  socket.on('close', drop);
561
692
  socket.on('error', drop);
@@ -568,12 +699,38 @@ function createCdpBroker({
568
699
 
569
700
  return {
570
701
  onSessionOpen(deviceId) {
702
+ const session = sessions.get(deviceId);
703
+ if (!session?.brokerReady) return;
704
+ const previous = knownTargets.get(deviceId);
705
+ const sessionChanged = observedSessions.get(deviceId) !== session;
706
+ knownTargets.set(deviceId, {
707
+ deviceId,
708
+ generation:
709
+ previous?.generation === undefined
710
+ ? 1
711
+ : previous.generation + (!previous.ready || sessionChanged ? 1 : 0),
712
+ name: String(session.name || previous?.name || ''),
713
+ ready: true,
714
+ });
715
+ observedSessions.set(deviceId, session);
716
+ const waiters = sessionWaiters.get(deviceId);
717
+ if (waiters) {
718
+ sessionWaiters.delete(deviceId);
719
+ for (const waiter of waiters) {
720
+ clearTimeout(waiter.timer);
721
+ waiter.resolve(session);
722
+ }
723
+ }
724
+ settleTargetWaiters();
571
725
  for (const capture of capturesFor(deviceId).values()) {
572
726
  if (capture.reconnects > 0) capture.partial = true;
573
727
  void enableNetwork(deviceId, capture);
574
728
  }
575
729
  },
576
730
  onSessionClose(deviceId) {
731
+ const previous = knownTargets.get(deviceId);
732
+ if (previous) knownTargets.set(deviceId, { ...previous, ready: false });
733
+ observedSessions.delete(deviceId);
577
734
  for (const capture of capturesFor(deviceId).values()) {
578
735
  capture.reconnects += 1;
579
736
  capture.partial = true;
@@ -621,6 +778,18 @@ function createCdpBroker({
621
778
  },
622
779
  close() {
623
780
  clearInterval(expirySweep);
781
+ for (const waiters of sessionWaiters.values()) {
782
+ for (const waiter of waiters) {
783
+ clearTimeout(waiter.timer);
784
+ waiter.reject(new Error('CDP broker closed'));
785
+ }
786
+ }
787
+ sessionWaiters.clear();
788
+ for (const waiter of targetWaiters) {
789
+ clearTimeout(waiter.timer);
790
+ waiter.reject(new Error('CDP broker closed'));
791
+ }
792
+ targetWaiters.clear();
624
793
  for (const socket of clients) socket.destroy();
625
794
  try {
626
795
  server.close();
@@ -696,7 +865,7 @@ function createBrokerClient(socketPath, deviceId, timeout) {
696
865
  });
697
866
  socket.on('data', (chunk) => {
698
867
  buffer += chunk;
699
- if (Buffer.byteLength(buffer) > MAX_RPC_FRAME_BYTES) {
868
+ if (Buffer.byteLength(buffer) > MAX_RESPONSE_FRAME_BYTES) {
700
869
  socket.destroy();
701
870
  for (const entry of pending.values()) {
702
871
  entry.reject(new Error('CDP broker response exceeds its bound'));
@@ -4,9 +4,9 @@ const fs = require('node:fs');
4
4
  const path = require('node:path');
5
5
 
6
6
  /** Read a value from .js.env */
7
- function loadEnvValue(key) {
7
+ function loadEnvValue(key, appRoot = process.env.APP_ROOT || process.cwd()) {
8
8
  try {
9
- const envPath = path.resolve(process.env.APP_ROOT || process.cwd(), '.js.env');
9
+ const envPath = path.resolve(appRoot, '.js.env');
10
10
  const content = fs.readFileSync(envPath, 'utf8');
11
11
  // .js.env uses `export KEY="value"` (shell-sourceable format),
12
12
  // so we handle the optional `export` prefix and strip surrounding quotes.
@@ -18,9 +18,19 @@ function loadEnvValue(key) {
18
18
  return undefined;
19
19
  }
20
20
 
21
+ function resolvePort(env = process.env, appRoot = env.APP_ROOT || process.cwd()) {
22
+ return (
23
+ env.WATCHER_PORT ||
24
+ env.METRO_PORT ||
25
+ loadEnvValue('WATCHER_PORT', appRoot) ||
26
+ loadEnvValue('METRO_PORT', appRoot) ||
27
+ '8081'
28
+ );
29
+ }
30
+
21
31
  /** Read WATCHER_PORT from .js.env or env (default: 8081) */
22
32
  function loadPort() {
23
- return Number.parseInt(process.env.WATCHER_PORT || loadEnvValue('WATCHER_PORT') || '8081', 10);
33
+ return Number.parseInt(resolvePort(), 10);
24
34
  }
25
35
 
26
36
  /** Read IOS_SIMULATOR name from .js.env or env (default: none — accept any device) */
@@ -48,4 +58,4 @@ function loadAndroidTargetDeviceName() {
48
58
  return process.env.ANDROID_TARGET_DEVICE_NAME || '';
49
59
  }
50
60
 
51
- module.exports = { loadEnvValue, loadPort, loadSimulatorName, loadAndroidDevice, loadAndroidTargetDeviceName };
61
+ module.exports = { loadEnvValue, loadPort, resolvePort, loadSimulatorName, loadAndroidDevice, loadAndroidTargetDeviceName };
@@ -3,7 +3,7 @@
3
3
  set -uo pipefail
4
4
 
5
5
  TARGET="$PWD"
6
- PORT="${WATCHER_PORT:-${METRO_PORT:-8081}}"
6
+ PORT="${WATCHER_PORT:-${METRO_PORT:-}}"
7
7
  QUIET=false
8
8
 
9
9
  while [ "$#" -gt 0 ]; do
@@ -22,6 +22,10 @@ done
22
22
  [ "${METAMASK_RECIPE_CONSOLE_FORWARD:-1}" != "0" ] || exit 0
23
23
  TARGET="$(cd "$TARGET" && pwd -P)"
24
24
  SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
25
+ if [ -z "$PORT" ] && command -v node >/dev/null 2>&1; then
26
+ PORT="$(APP_ROOT="$TARGET" CONFIG_PATH="$SCRIPT_DIR/bridge-runtime/lib/config.cjs" node -e "process.stdout.write(String(require(process.env.CONFIG_PATH).resolvePort()))")"
27
+ fi
28
+ PORT="${PORT:-8081}"
25
29
  # shellcheck disable=SC1091
26
30
  . "$SCRIPT_DIR/../shared/harness-path.sh"
27
31
  LOG_DIR="$TARGET/$(recipe_runtime_dir)" || exit 1