@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
@@ -35,10 +35,12 @@ 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.
41
42
  const HANDSHAKE_TIMEOUT_MS = 3000;
43
+ const RUNTIME_ENABLE_TIMEOUT_MS = 60_000;
42
44
 
43
45
  const DISCOVER_ACTIVE_MS = 1000; // a device is unattached — look for it quickly
44
46
  const DISCOVER_STEADY_MS = 10000; // all known targets attached — cheap liveness tick
@@ -46,7 +48,7 @@ const FLUSH_MS = 100;
46
48
  const MAX_LINE_CHARS = 4000;
47
49
 
48
50
  function parseArgs(argv) {
49
- const args = { port: process.env.WATCHER_PORT || '8081', out: null };
51
+ const args = { port: resolvePort(), out: null };
50
52
  for (let i = 2; i < argv.length; i += 1) {
51
53
  if (argv[i] === '--port') args.port = argv[++i];
52
54
  else if (argv[i] === '--out') args.out = argv[++i];
@@ -208,7 +210,7 @@ function sendCommand(session, method, params = {}, timeoutMs = 10_000) {
208
210
  }
209
211
 
210
212
  broker = createCdpBroker({
211
- socketPath: brokerSocketPath(path.dirname(out)),
213
+ socketPath: brokerSocketPath(path.dirname(out), port),
212
214
  sessions,
213
215
  sendCommand,
214
216
  requestDiscovery(deviceId) {
@@ -310,25 +312,36 @@ function connect(target) {
310
312
  ws.addEventListener('open', async () => {
311
313
  clearTimeout(handshakeTimer);
312
314
  session.opened = true;
315
+ // A live inspector socket is enough for broker commands. Do not keep the
316
+ // broker unavailable while Runtime.enable replays a large console buffer or
317
+ // while __AGENTIC__ is being installed after a Hermes context handoff.
318
+ // Commands such as status already report agenticPresent=false and their
319
+ // callers retry against the same bounded action deadline.
320
+ session.brokerReady = true;
321
+ broker.onSessionOpen(deviceId);
322
+ devtoolsProxy.onSessionOpen(deviceId, session);
323
+ process.stderr.write(`console-forwarder: attached ${name}\n`);
313
324
  try {
314
- await sendCommand(session, 'Runtime.enable');
325
+ await sendCommand(
326
+ session,
327
+ 'Runtime.enable',
328
+ {},
329
+ RUNTIME_ENABLE_TIMEOUT_MS,
330
+ );
315
331
  const evaluation = await sendCommand(session, 'Runtime.evaluate', {
316
332
  expression: "typeof globalThis.__AGENTIC__ === 'object'",
317
333
  returnByValue: true,
318
334
  awaitPromise: false,
319
335
  });
320
336
  if (evaluation?.result?.value !== true) {
321
- throw new Error('CDP target does not expose __AGENTIC__');
337
+ process.stderr.write(
338
+ `console-forwarder: ${name} is waiting for __AGENTIC__\n`,
339
+ );
322
340
  }
323
- session.brokerReady = true;
324
- broker.onSessionOpen(deviceId);
325
- devtoolsProxy.onSessionOpen(deviceId, session);
326
- process.stderr.write(`console-forwarder: attached ${name}\n`);
327
341
  } catch (error) {
328
342
  process.stderr.write(
329
- `console-forwarder: rejected ${name}: ${String(error?.message || error).slice(0, 256)}\n`,
343
+ `console-forwarder: console bootstrap pending for ${name}: ${String(error?.message || error).slice(0, 256)}\n`,
330
344
  );
331
- ws.close();
332
345
  }
333
346
  });
334
347
  ws.addEventListener('message', (event) => {
@@ -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,22 +12,40 @@ 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;
17
+ const MAX_HUD_STEP_BYTES = 16 * 1024;
18
+ const MAX_HUD_UPDATES = 64;
16
19
  const MAX_RETAINED_STRING_LENGTH = 256;
17
20
  const MAX_BROKER_TIMEOUT_MS = 60_000;
18
21
  const SENSITIVE_FIELD =
19
22
  /(?:address|authorization|cookie|key|password|secret|token|user|account)/iu;
20
23
  const SENSITIVE_VALUE =
21
24
  /(?:0x[a-f0-9]{40,}|[a-f0-9]{64,}|eyJ[a-z0-9_-]{20,}\.[a-z0-9_-]{20,})/iu;
25
+ const APPLY_HUD_UPDATE_FUNCTION = `function(step) {
26
+ const bridge = this.__AGENTIC__;
27
+ if (step === null) {
28
+ if (typeof bridge?.hideStep !== 'function') return false;
29
+ setTimeout(() => bridge.hideStep(), 0);
30
+ return true;
31
+ }
32
+ if (typeof bridge?.showStep !== 'function') return false;
33
+ setTimeout(() => bridge.showStep(step), 0);
34
+ return true;
35
+ }`;
22
36
 
23
- function brokerSocketPath(runtimeDir) {
37
+ function brokerSocketPath(runtimeDir, endpointIdentity) {
24
38
  const runtimePath = path.resolve(
25
39
  runtimeDir ||
26
40
  process.env.RECIPE_RUNTIME_DIR ||
27
41
  path.join('temp', 'recipe', 'runtime'),
28
42
  );
43
+ const socketIdentity =
44
+ endpointIdentity === undefined || endpointIdentity === null
45
+ ? runtimePath
46
+ : `${runtimePath}\u0000${String(endpointIdentity)}`;
29
47
  const identity = createHash('sha256')
30
- .update(runtimePath)
48
+ .update(socketIdentity)
31
49
  .digest('hex')
32
50
  .slice(0, 20);
33
51
  return path.join('/tmp', `mmh-cdp-${identity}.sock`);
@@ -77,6 +95,23 @@ function boundedTimeout(value, fallback = 10_000) {
77
95
  : fallback;
78
96
  }
79
97
 
98
+ // State recovery is event-driven. Pick a fixed bound below the caller's RPC
99
+ // deadline so the broker always answers before the client gives up. Returning
100
+ // literals also keeps the timer independent from untrusted request values.
101
+ function stateWaitTimeout(value) {
102
+ const timeout = boundedTimeout(value);
103
+ if (timeout <= 500) return 1;
104
+ if (timeout <= 1_000) return 400;
105
+ if (timeout <= 2_000) return 900;
106
+ if (timeout <= 5_000) return 1_900;
107
+ if (timeout <= 10_000) return 4_500;
108
+ if (timeout <= 15_000) return 9_500;
109
+ if (timeout <= 30_000) return 14_500;
110
+ if (timeout <= 45_000) return 29_500;
111
+ if (timeout <= 55_000) return 44_500;
112
+ return 54_500;
113
+ }
114
+
80
115
  function readField(value, field) {
81
116
  let current = value;
82
117
  for (const segment of String(field).split('.')) {
@@ -331,8 +366,30 @@ function createCdpBroker({
331
366
  }) {
332
367
  const captures = new Map();
333
368
  const completedCaptures = new Map();
369
+ const hudUpdates = new Map();
370
+ const appliedHudVersions = new Map();
371
+ const hudApplyChains = new Map();
334
372
  const clients = new Set();
335
373
  const subscriptions = new Map();
374
+ const knownTargets = new Map(
375
+ [...sessions.entries()].flatMap(([deviceId, session]) =>
376
+ session?.brokerReady
377
+ ? [[deviceId, {
378
+ deviceId,
379
+ generation: 1,
380
+ name: String(session.name || ''),
381
+ ready: true,
382
+ }]]
383
+ : [],
384
+ ),
385
+ );
386
+ const sessionWaiters = new Map();
387
+ const targetWaiters = new Set();
388
+ const observedSessions = new Map(
389
+ [...sessions.entries()].flatMap(([deviceId, session]) =>
390
+ session?.brokerReady ? [[deviceId, session]] : [],
391
+ ),
392
+ );
336
393
 
337
394
  const capturesFor = (deviceId) => {
338
395
  if (!captures.has(deviceId)) captures.set(deviceId, new Map());
@@ -346,23 +403,83 @@ function createCdpBroker({
346
403
  return completedCaptures.get(deviceId);
347
404
  };
348
405
 
349
- async function waitForSession(deviceId, timeoutMs) {
406
+ function targetList({ nameIncludes = '', readyOnly = false } = {}) {
407
+ const pin = String(nameIncludes).trim().toLowerCase();
408
+ return [...knownTargets.values()]
409
+ .filter((target) => !readyOnly || target.ready)
410
+ .filter((target) => !pin || target.name.toLowerCase().includes(pin))
411
+ .map((target) => ({ ...target }))
412
+ .sort((a, b) => a.name.localeCompare(b.name));
413
+ }
414
+
415
+ function settleTargetWaiters() {
416
+ for (const waiter of [...targetWaiters]) {
417
+ const targets = targetList({ nameIncludes: waiter.nameIncludes });
418
+ if (targets.length === 0) continue;
419
+ targetWaiters.delete(waiter);
420
+ clearTimeout(waiter.timer);
421
+ waiter.resolve(targets);
422
+ }
423
+ }
424
+
425
+ function resolveTargets(params, timeoutMs, socket) {
426
+ const existing = targetList({ nameIncludes: params.nameIncludes });
427
+ if (existing.length > 0) return Promise.resolve(existing);
428
+ requestDiscovery?.('');
429
+ return new Promise((resolve, reject) => {
430
+ const waiter = {
431
+ nameIncludes: String(params.nameIncludes || ''),
432
+ resolve,
433
+ reject,
434
+ socket,
435
+ timer: null,
436
+ };
437
+ waiter.timer = setTimeout(() => {
438
+ targetWaiters.delete(waiter);
439
+ reject(new Error('CDP broker never observed the requested target'));
440
+ }, stateWaitTimeout(timeoutMs));
441
+ targetWaiters.add(waiter);
442
+ settleTargetWaiters();
443
+ });
444
+ }
445
+
446
+ function waitForSession(deviceId, timeoutMs, socket) {
350
447
  if (sessions.get(deviceId)?.brokerReady) return sessions.get(deviceId);
351
448
  requestDiscovery?.(deviceId);
352
- const deadline = Date.now() + boundedTimeout(timeoutMs);
353
- while (Date.now() < deadline) {
354
- await new Promise((resolve) => setTimeout(resolve, 100));
449
+ return new Promise((resolve, reject) => {
450
+ const waiters = sessionWaiters.get(deviceId) || new Set();
451
+ const waiter = {
452
+ resolve,
453
+ reject,
454
+ socket,
455
+ timer: setTimeout(() => {
456
+ waiters.delete(waiter);
457
+ if (waiters.size === 0) sessionWaiters.delete(deviceId);
458
+ reject(new Error(`CDP broker target unavailable: ${deviceId}`));
459
+ }, stateWaitTimeout(timeoutMs)),
460
+ };
461
+ waiters.add(waiter);
462
+ sessionWaiters.set(deviceId, waiters);
355
463
  const session = sessions.get(deviceId);
356
- if (session?.brokerReady) return session;
357
- }
358
- throw new Error('CDP broker target unavailable');
464
+ if (session?.brokerReady) {
465
+ waiters.delete(waiter);
466
+ if (waiters.size === 0) sessionWaiters.delete(deviceId);
467
+ clearTimeout(waiter.timer);
468
+ resolve(session);
469
+ }
470
+ });
359
471
  }
360
472
 
361
- async function enableNetwork(deviceId, capture, timeoutMs = 10_000) {
473
+ async function enableNetwork(
474
+ deviceId,
475
+ capture,
476
+ timeoutMs = 10_000,
477
+ socket,
478
+ ) {
362
479
  try {
363
480
  const budgetMs = boundedTimeout(timeoutMs);
364
481
  const deadline = Date.now() + budgetMs;
365
- const session = await waitForSession(deviceId, budgetMs);
482
+ const session = await waitForSession(deviceId, budgetMs, socket);
366
483
  const remainingMs = Math.max(1, deadline - Date.now());
367
484
  await sendCommand(session, 'Network.enable', {}, remainingMs);
368
485
  if (capture.enableErrors.length > 0) capture.partial = true;
@@ -381,6 +498,67 @@ function createCdpBroker({
381
498
  capturesFor(deviceId).delete(capture.id);
382
499
  }
383
500
 
501
+ async function applyHudUpdate(deviceId, timeoutMs, socket) {
502
+ if (!hudUpdates.has(deviceId)) return { status: 'none' };
503
+ const budgetMs = boundedTimeout(timeoutMs);
504
+ const deadline = Date.now() + budgetMs;
505
+ const session = await waitForSession(deviceId, budgetMs, socket);
506
+ const update = hudUpdates.get(deviceId);
507
+ if (!update) return { status: 'none' };
508
+ if (appliedHudVersions.get(deviceId) === update.version) {
509
+ return { status: 'applied', version: update.version };
510
+ }
511
+ let applied = false;
512
+ while (Date.now() < deadline) {
513
+ const globalObject = await sendCommand(
514
+ session,
515
+ 'Runtime.evaluate',
516
+ { expression: 'globalThis', returnByValue: false, awaitPromise: false },
517
+ Math.max(1, deadline - Date.now()),
518
+ );
519
+ const objectId = globalObject?.result?.objectId;
520
+ if (!objectId) {
521
+ throw new Error('Mobile HUD runtime did not expose globalThis');
522
+ }
523
+ const evaluation = await sendCommand(
524
+ session,
525
+ 'Runtime.callFunctionOn',
526
+ {
527
+ objectId,
528
+ functionDeclaration: APPLY_HUD_UPDATE_FUNCTION,
529
+ arguments: [{ value: update.step }],
530
+ returnByValue: true,
531
+ awaitPromise: false,
532
+ },
533
+ Math.max(1, deadline - Date.now()),
534
+ );
535
+ if (evaluation?.result?.value === true) {
536
+ applied = true;
537
+ break;
538
+ }
539
+ await new Promise((resolve) => setTimeout(resolve, 100));
540
+ }
541
+ if (!applied) {
542
+ throw new Error('Mobile HUD bridge was not installed before the update deadline');
543
+ }
544
+ if (hudUpdates.get(deviceId)?.version === update.version) {
545
+ appliedHudVersions.set(deviceId, update.version);
546
+ }
547
+ return { status: 'applied', version: update.version };
548
+ }
549
+
550
+ function scheduleHudUpdate(deviceId, timeoutMs, socket) {
551
+ const previous = hudApplyChains.get(deviceId) || Promise.resolve();
552
+ const next = previous
553
+ .catch(() => undefined)
554
+ .then(() => applyHudUpdate(deviceId, timeoutMs, socket));
555
+ hudApplyChains.set(deviceId, next);
556
+ void next.finally(() => {
557
+ if (hudApplyChains.get(deviceId) === next) hudApplyChains.delete(deviceId);
558
+ }).catch(() => undefined);
559
+ return next;
560
+ }
561
+
384
562
  function expireCapture(deviceId, capture) {
385
563
  if (capturesFor(deviceId).get(capture.id) !== capture) return;
386
564
  finishCapture(deviceId, capture, true);
@@ -422,15 +600,44 @@ function createCdpBroker({
422
600
  };
423
601
  }
424
602
 
425
- async function control(deviceId, action, params, timeoutMs = 10_000) {
603
+ async function control(
604
+ deviceId,
605
+ action,
606
+ params,
607
+ timeoutMs = 10_000,
608
+ socket,
609
+ ) {
426
610
  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));
611
+ return targetList({ readyOnly: true }).map(({ deviceId, name }) => ({
612
+ deviceId,
613
+ name,
614
+ }));
615
+ }
616
+ if (action === 'resolve-targets') {
617
+ return resolveTargets(params, timeoutMs, socket);
618
+ }
619
+ if (action === 'hud-update') {
620
+ const step = params.step ?? null;
621
+ if (step !== null && (typeof step !== 'object' || Array.isArray(step))) {
622
+ throw new Error('HUD update requires an object step or null');
623
+ }
624
+ if (Buffer.byteLength(JSON.stringify(step)) > MAX_HUD_STEP_BYTES) {
625
+ throw new Error('HUD update exceeds its bound');
626
+ }
627
+ const previousVersion = hudUpdates.get(deviceId)?.version || 0;
628
+ if (hudUpdates.has(deviceId)) {
629
+ hudUpdates.delete(deviceId);
630
+ }
631
+ while (hudUpdates.size >= MAX_HUD_UPDATES) {
632
+ const oldestDeviceId = hudUpdates.keys().next().value;
633
+ hudUpdates.delete(oldestDeviceId);
634
+ appliedHudVersions.delete(oldestDeviceId);
635
+ }
636
+ const version = previousVersion + 1;
637
+ hudUpdates.set(deviceId, { step, version });
638
+ requestDiscovery?.(deviceId);
639
+ void scheduleHudUpdate(deviceId, 10_000).catch(() => undefined);
640
+ return { status: 'queued', version };
434
641
  }
435
642
  if (action === 'capture-start') {
436
643
  const capture = normalizeCapture(params);
@@ -443,7 +650,12 @@ function createCdpBroker({
443
650
  }
444
651
  completedCapturesFor(deviceId).delete(capture.id);
445
652
  deviceCaptures.set(capture.id, capture);
446
- await enableNetwork(deviceId, capture, Math.max(1, timeoutMs - 500));
653
+ await enableNetwork(
654
+ deviceId,
655
+ capture,
656
+ Math.max(1, timeoutMs - 500),
657
+ socket,
658
+ );
447
659
  return { id: capture.id, status: 'started' };
448
660
  }
449
661
  if (action === 'capture-end') {
@@ -467,15 +679,18 @@ function createCdpBroker({
467
679
  try {
468
680
  onClientActivity?.();
469
681
  if (request.type === 'command') {
682
+ const budgetMs = boundedTimeout(request.timeoutMs);
683
+ const deadline = Date.now() + budgetMs;
470
684
  const session = await waitForSession(
471
685
  request.deviceId,
472
- boundedTimeout(request.timeoutMs),
686
+ budgetMs,
687
+ socket,
473
688
  );
474
689
  const result = await sendCommand(
475
690
  session,
476
691
  request.method,
477
692
  request.params || {},
478
- boundedTimeout(request.timeoutMs),
693
+ Math.max(1, deadline - Date.now()),
479
694
  );
480
695
  writeMessage(socket, { id: request.id, result });
481
696
  return;
@@ -486,6 +701,7 @@ function createCdpBroker({
486
701
  request.action,
487
702
  request.params || {},
488
703
  boundedTimeout(request.timeoutMs),
704
+ socket,
489
705
  );
490
706
  writeMessage(socket, { id: request.id, result });
491
707
  return;
@@ -532,7 +748,7 @@ function createCdpBroker({
532
748
  socket.setEncoding('utf8');
533
749
  socket.on('data', (chunk) => {
534
750
  buffer += chunk;
535
- if (Buffer.byteLength(buffer) > MAX_RPC_FRAME_BYTES) {
751
+ if (Buffer.byteLength(buffer) > MAX_REQUEST_FRAME_BYTES) {
536
752
  writeMessage(socket, { error: 'CDP broker request exceeds its bound' });
537
753
  socket.destroy();
538
754
  return;
@@ -556,6 +772,21 @@ function createCdpBroker({
556
772
  const drop = () => {
557
773
  clients.delete(socket);
558
774
  subscriptions.delete(socket);
775
+ for (const waiter of [...targetWaiters]) {
776
+ if (waiter.socket !== socket) continue;
777
+ targetWaiters.delete(waiter);
778
+ clearTimeout(waiter.timer);
779
+ waiter.reject(new Error('CDP broker client closed'));
780
+ }
781
+ for (const [deviceId, waiters] of sessionWaiters) {
782
+ for (const waiter of [...waiters]) {
783
+ if (waiter.socket !== socket) continue;
784
+ waiters.delete(waiter);
785
+ clearTimeout(waiter.timer);
786
+ waiter.reject(new Error('CDP broker client closed'));
787
+ }
788
+ if (waiters.size === 0) sessionWaiters.delete(deviceId);
789
+ }
559
790
  };
560
791
  socket.on('close', drop);
561
792
  socket.on('error', drop);
@@ -568,12 +799,40 @@ function createCdpBroker({
568
799
 
569
800
  return {
570
801
  onSessionOpen(deviceId) {
802
+ const session = sessions.get(deviceId);
803
+ if (!session?.brokerReady) return;
804
+ const previous = knownTargets.get(deviceId);
805
+ const sessionChanged = observedSessions.get(deviceId) !== session;
806
+ knownTargets.set(deviceId, {
807
+ deviceId,
808
+ generation:
809
+ previous?.generation === undefined
810
+ ? 1
811
+ : previous.generation + (!previous.ready || sessionChanged ? 1 : 0),
812
+ name: String(session.name || previous?.name || ''),
813
+ ready: true,
814
+ });
815
+ observedSessions.set(deviceId, session);
816
+ const waiters = sessionWaiters.get(deviceId);
817
+ if (waiters) {
818
+ sessionWaiters.delete(deviceId);
819
+ for (const waiter of waiters) {
820
+ clearTimeout(waiter.timer);
821
+ waiter.resolve(session);
822
+ }
823
+ }
824
+ settleTargetWaiters();
571
825
  for (const capture of capturesFor(deviceId).values()) {
572
826
  if (capture.reconnects > 0) capture.partial = true;
573
827
  void enableNetwork(deviceId, capture);
574
828
  }
829
+ void scheduleHudUpdate(deviceId, 10_000).catch(() => undefined);
575
830
  },
576
831
  onSessionClose(deviceId) {
832
+ const previous = knownTargets.get(deviceId);
833
+ if (previous) knownTargets.set(deviceId, { ...previous, ready: false });
834
+ observedSessions.delete(deviceId);
835
+ appliedHudVersions.delete(deviceId);
577
836
  for (const capture of capturesFor(deviceId).values()) {
578
837
  capture.reconnects += 1;
579
838
  capture.partial = true;
@@ -621,6 +880,18 @@ function createCdpBroker({
621
880
  },
622
881
  close() {
623
882
  clearInterval(expirySweep);
883
+ for (const waiters of sessionWaiters.values()) {
884
+ for (const waiter of waiters) {
885
+ clearTimeout(waiter.timer);
886
+ waiter.reject(new Error('CDP broker closed'));
887
+ }
888
+ }
889
+ sessionWaiters.clear();
890
+ for (const waiter of targetWaiters) {
891
+ clearTimeout(waiter.timer);
892
+ waiter.reject(new Error('CDP broker closed'));
893
+ }
894
+ targetWaiters.clear();
624
895
  for (const socket of clients) socket.destroy();
625
896
  try {
626
897
  server.close();
@@ -696,7 +967,7 @@ function createBrokerClient(socketPath, deviceId, timeout) {
696
967
  });
697
968
  socket.on('data', (chunk) => {
698
969
  buffer += chunk;
699
- if (Buffer.byteLength(buffer) > MAX_RPC_FRAME_BYTES) {
970
+ if (Buffer.byteLength(buffer) > MAX_RESPONSE_FRAME_BYTES) {
700
971
  socket.destroy();
701
972
  for (const entry of pending.values()) {
702
973
  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 };
@@ -300,6 +300,7 @@ async function discoverTarget(port, { probe = true } = {}) {
300
300
  for (const candidate of candidates) {
301
301
  if (candidate.webSocketDebuggerUrl === acceptedPinnedCandidate.webSocketDebuggerUrl) {
302
302
  return {
303
+ id: acceptedPinnedCandidate.id || '',
303
304
  wsUrl: acceptedPinnedCandidate.webSocketDebuggerUrl,
304
305
  deviceName: acceptedPinnedCandidate.deviceName || '',
305
306
  };
@@ -312,6 +313,7 @@ async function discoverTarget(port, { probe = true } = {}) {
312
313
  // platform/device filtering and select the highest-ranked runtime instead.
313
314
  if (!probe) {
314
315
  return {
316
+ id: candidates[0].id || '',
315
317
  wsUrl: candidates[0].webSocketDebuggerUrl,
316
318
  deviceName: candidates[0].deviceName || '',
317
319
  };
@@ -331,7 +333,7 @@ async function discoverTarget(port, { probe = true } = {}) {
331
333
  for (const candidate of candidates) {
332
334
  const hasAgentic = await probeTarget(candidate.webSocketDebuggerUrl);
333
335
  if (hasAgentic) {
334
- return { wsUrl: candidate.webSocketDebuggerUrl, deviceName: candidate.deviceName || '' };
336
+ return { id: candidate.id || '', wsUrl: candidate.webSocketDebuggerUrl, deviceName: candidate.deviceName || '' };
335
337
  }
336
338
  }
337
339
 
@@ -348,7 +350,7 @@ async function discoverTarget(port, { probe = true } = {}) {
348
350
  }
349
351
 
350
352
  // Fallback: return highest page number (most likely the JS runtime)
351
- return { wsUrl: candidates[0].webSocketDebuggerUrl, deviceName: candidates[0].deviceName || '' };
353
+ return { id: candidates[0].id || '', wsUrl: candidates[0].webSocketDebuggerUrl, deviceName: candidates[0].deviceName || '' };
352
354
  }
353
355
 
354
356
  /**