@deeeed/metamask-harness 0.34.0 → 0.34.2

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (33) hide show
  1. package/CHANGELOG.md +29 -0
  2. package/adapters/manifest.json +0 -8
  3. package/adapters/mobile/bridge-runtime/cdp-bridge.cjs +236 -3
  4. package/adapters/mobile/bridge-runtime/console-forwarder.cjs +22 -1
  5. package/adapters/mobile/bridge-runtime/lib/target-discovery.cjs +22 -15
  6. package/adapters/mobile/bridge-runtime/lib/ws-client.cjs +18 -0
  7. package/adapters/mobile/launch-metro.cjs +6 -4
  8. package/adapters/mobile/open-device.sh +170 -19
  9. package/adapters/mobile/start-metro.sh +165 -93
  10. package/adapters/mobile/stop-metro.sh +1 -0
  11. package/dist/adapters/mobile/prepare.js +1 -4
  12. package/dist/adapters/mobile/video-recorder.js +12 -10
  13. package/dist/adapters.js +134 -6
  14. package/dist/commands/launch/index.js +9 -0
  15. package/dist/commands/launch/mobile.js +17 -3
  16. package/dist/live-adapter-contract.js +10 -3
  17. package/dist/recipe-security.js +2 -0
  18. package/dist/runner.js +53 -8
  19. package/dist/runtime-context.js +1 -0
  20. package/library/actions/mobile/perps/measure_homepage_visible.mjs +4 -0
  21. package/library/actions/mobile/perps/performance-capture.mjs +579 -111
  22. package/library/actions/mobile/perps/perps.mjs +221 -38
  23. package/library/actions/mobile/perps/prepare_local_snapshot_endpoint.mjs +58 -0
  24. package/library/actions/mobile/platform/bridge.mjs +102 -8
  25. package/library/actions/mobile/wallet/ensure_unlocked.mjs +35 -8
  26. package/library/manifests/mobile.action-manifest.json +243 -511
  27. package/library/recipes/mobile/perps/performance.homepage.android-cold-disk-cache.recipe.json +20 -2
  28. package/library/recipes/mobile/perps/performance.homepage.android-cold-no-cache.recipe.json +2 -0
  29. package/library/recipes/mobile/perps/performance.homepage.cold-position-sample.recipe.json +120 -0
  30. package/library/recipes/mobile/perps/performance.homepage.ios-background-reconnect.recipe.json +7 -6
  31. package/library/recipes/mobile/perps/performance.homepage.ios-cold-no-cache.recipe.json +5 -5
  32. package/package.json +1 -1
  33. package/adapters/mobile/metro-config.cjs +0 -93
package/CHANGELOG.md CHANGED
@@ -2,6 +2,35 @@
2
2
 
3
3
  ## Unreleased
4
4
 
5
+ ## 0.34.2 - 2026-08-11
6
+
7
+ ### Added
8
+
9
+ - Add composable platform-neutral Homepage Perps position setup and background-lifecycle measurement recipes.
10
+
11
+ ### Fixed
12
+
13
+ - Give Mobile foreground lifecycle actions the same bounded 60-second runtime-reacquisition window as launch and restart.
14
+ - Classify iOS foreground as a same-process resume or cold relaunch, and let reconnect recipes reject process-evicted samples immediately.
15
+ - Quiesce active Perps streams before direct-CDP performance cache removal and verify the disk keys remain absent.
16
+ - Exclude queued pre-lifecycle socket frames from lifecycle-specific Fresh DFD measurements.
17
+
18
+ ## 0.34.1 - 2026-08-09
19
+
20
+ ### Fixed
21
+
22
+ - Launch Mobile Metro through the product's canonical watcher while preserving slot, build, Sentry, and Segment environment settings, applying cache clears only to the selected Metro instance, and removing the config override that could corrupt cold bundles.
23
+ - Pin native Mobile builds to the exact supported Main or Flask app identity and Android device, rejecting ambiguous physical-device names instead of building or launching on the wrong target.
24
+ - Clean up timed-out Metro process groups safely and require the cold-disk-cache Perps freshness gate to report the expected lifecycle.
25
+ - Allow performance recipes to enforce a first-fresh socket-to-subscriber latency budget for a stream and subscriber throttle, optionally scoped to a lifecycle while always reporting the observed lifecycle.
26
+ - Allow Mobile wallet unlock actions to use their declared target and unlock timeouts without the live-adapter process ending first.
27
+ - Remove Android's 180-second `screenrecord` cap so `--record-video=full-run` covers long recipe runs.
28
+ - Retry empty Metro target lists, derive the Android model pin from `ADB_SERIAL`, and use pin-aware wallet status while Hermes re-registers between recipe actions.
29
+ - Keep compound Mobile actions under one debugger lease so nested bridge calls cannot race another recipe action.
30
+ - Let Android `scroll_into_view` fall back to a scrollable ancestor when the target itself is not a scroll container.
31
+ - Leave Metro's worker count at its native default unless `METRO_MAX_WORKERS` is explicitly configured.
32
+ - Bound each Mobile wallet-status probe by its polling deadline, raise the default unlock verification window to 30 seconds, and give the Android cold-no-cache recipe an explicit cold-start allowance, preventing a successful physical-device unlock from being reported as a timeout.
33
+
5
34
  ## 0.34.0 - 2026-08-07
6
35
 
7
36
  ### Fixed
@@ -42,14 +42,6 @@
42
42
  "inputs": "--target --port --log --pid-file [--workers] [--clear]",
43
43
  "outputs": "Metro PID/launch metadata and redirected log; exit 0/1/2"
44
44
  },
45
- {
46
- "id": "mobile/metro-config",
47
- "entry": "adapters/mobile/metro-config.cjs",
48
- "kind": "module",
49
- "purpose": "Preserve the product Metro config while excluding harness-owned checkout temp artifacts.",
50
- "inputs": "base Metro config; env MM_HARNESS_METRO_PROJECT_ROOT, MM_HARNESS_METRO_BASE_CONFIG",
51
- "outputs": "merged Metro config with resolver.blockList for <projectRoot>/temp"
52
- },
53
45
  {
54
46
  "id": "mobile/stop-metro",
55
47
  "entry": "adapters/mobile/stop-metro.sh",
@@ -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
  // ---------------------------------------------------------------------------
@@ -322,7 +335,11 @@ const COMMANDS = {
322
335
  const optsJson = JSON.stringify({ testId, offset, animated, intoView });
323
336
  // Try __AGENTIC__ bridge first, fall back to inline fiber walking
324
337
  const expr = `(function() {
325
- if (globalThis.__AGENTIC__?.scrollView) return globalThis.__AGENTIC__.scrollView(${optsJson});
338
+ // The app helper implements generic scrolling by searching inside the
339
+ // selector fiber. For into-view requests the scroll container is commonly
340
+ // an ancestor (for example, a Wallet Home section row), so use the
341
+ // ancestor-aware bridge fallback below instead.
342
+ if (!${intoView} && globalThis.__AGENTIC__?.scrollView) return globalThis.__AGENTIC__.scrollView(${optsJson});
326
343
  var hook = globalThis.__REACT_DEVTOOLS_GLOBAL_HOOK__;
327
344
  if (!hook) return { ok: false, error: 'No React DevTools hook' };
328
345
  var renderers = hook.renderers;
@@ -383,6 +400,214 @@ const COMMANDS = {
383
400
  return { ...result, deviceName };
384
401
  },
385
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
+
386
611
  async 'set-input'(client, args, { deviceName } = {}) {
387
612
  const testId = args[0];
388
613
  const value = args.slice(1).join(' ');
@@ -681,6 +906,11 @@ const BRIDGE_LOCK_FILE = path.join(
681
906
  process.env.RECIPE_RUNTIME_DIR || path.join('temp', 'recipe', 'runtime'),
682
907
  'cdp-bridge.lock',
683
908
  );
909
+ const BRIDGE_LOCK_OWNER_PID = /^\d+$/.test(
910
+ process.env.CDP_BRIDGE_LOCK_OWNER_PID || '',
911
+ )
912
+ ? process.env.CDP_BRIDGE_LOCK_OWNER_PID
913
+ : String(process.pid);
684
914
 
685
915
  // Ownership semantics: last writer wins. Each bridge stamps its own pid; the
686
916
  // forwarder honors the lock while the pid IN THE FILE is alive. Overlapping
@@ -691,7 +921,7 @@ let bridgeLockHeld = false;
691
921
 
692
922
  function acquireBridgeLock() {
693
923
  try {
694
- fs.writeFileSync(BRIDGE_LOCK_FILE, String(process.pid));
924
+ fs.writeFileSync(BRIDGE_LOCK_FILE, BRIDGE_LOCK_OWNER_PID);
695
925
  bridgeLockHeld = true;
696
926
  } catch {
697
927
  // Lock is best-effort: a missing runtime dir must not break bridge commands.
@@ -745,6 +975,7 @@ Commands:
745
975
  press-text <text> Press a component containing visible text
746
976
  scroll-view [--test-id <id>] [--offset <n>] [--animated]
747
977
  Scroll a ScrollView/FlatList
978
+ measure-scroll-transition <json> Scroll and measure a visible target using one CDP session
748
979
  set-input <testId> <value> Set text input value by testID (calls onChangeText)
749
980
  sentry-debug [enable|disable] Patch Sentry to log errors to console with [SENTRY-DEBUG] prefix
750
981
  unlock <password> Unlock wallet (inject password + press login button via fiber tree)
@@ -765,7 +996,7 @@ Environment:
765
996
  process.exit(0);
766
997
  }
767
998
 
768
- const handler = COMMANDS[command];
999
+ const handler = COMMANDS[command === 'status-selected' ? 'status' : command];
769
1000
  if (!handler) {
770
1001
  console.error(`Unknown command: ${command}`);
771
1002
  console.error(`Available: ${Object.keys(COMMANDS).join(', ')}`);
@@ -780,6 +1011,8 @@ Environment:
780
1011
  const timeout = Number.parseInt(process.env.CDP_TIMEOUT || '5000', 10);
781
1012
 
782
1013
  // `status` probes ALL connected targets so both platforms are visible.
1014
+ // `status-selected` falls through to the normal pinned discovery path for
1015
+ // action code that must survive one device's Hermes runtime rotation.
783
1016
  if (command === 'status') {
784
1017
  const { discoverAllTargets } = require('./lib/target-discovery.cjs');
785
1018
  const allTargets = await discoverAllTargets(port);
@@ -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
  }
@@ -6,7 +6,12 @@ const { createWSClient } = require('./ws-client.cjs');
6
6
  const { BRIDGE_ERROR_CODES, coded } = require('./bridge-errors.cjs');
7
7
 
8
8
  const FETCH_TIMEOUT_MS = Number.parseInt(process.env.CDP_TIMEOUT || '30000', 10);
9
- const FETCH_RETRIES = Number.parseInt(process.env.CDP_DISCOVERY_RETRIES || '3', 10);
9
+ const DISCOVERY_RETRY_DELAY_MS = 500;
10
+ const FETCH_RETRIES = Number.parseInt(
11
+ process.env.CDP_DISCOVERY_RETRIES ||
12
+ String(Math.max(3, Math.ceil(FETCH_TIMEOUT_MS / DISCOVERY_RETRY_DELAY_MS))),
13
+ 10,
14
+ );
10
15
 
11
16
  /** Fetch JSON from a URL (http only, no external deps) */
12
17
  function fetchJSONOnce(url) {
@@ -43,7 +48,9 @@ async function fetchJSON(url, acceptResponse) {
43
48
  lastError = error;
44
49
  }
45
50
  if (attempt === FETCH_RETRIES) break;
46
- await new Promise((resolve) => setTimeout(resolve, attempt * 1000));
51
+ await new Promise((resolve) =>
52
+ setTimeout(resolve, DISCOVERY_RETRY_DELAY_MS),
53
+ );
47
54
  }
48
55
  if (lastError !== undefined) throw lastError;
49
56
  if (lastResponse !== undefined) return lastResponse;
@@ -146,21 +153,21 @@ async function discoverTarget(port) {
146
153
  let acceptedPinnedCandidate;
147
154
  let targets;
148
155
  try {
149
- targets = await fetchJSON(
150
- listUrl,
151
- androidPinned
152
- ? async (response) => {
153
- const pinnedCandidates = rankRuntimeCandidates(response).filter(matchesAndroidPin);
154
- for (const candidate of pinnedCandidates) {
155
- if (await probeTarget(candidate.webSocketDebuggerUrl)) {
156
- acceptedPinnedCandidate = candidate;
157
- return true;
158
- }
156
+ targets = await fetchJSON(listUrl, async (response) => {
157
+ const runtimeCandidates = rankRuntimeCandidates(response);
158
+ if (runtimeCandidates.length === 0) return false;
159
+ if (androidPinned) {
160
+ const pinnedCandidates = runtimeCandidates.filter(matchesAndroidPin);
161
+ for (const candidate of pinnedCandidates) {
162
+ if (await probeTarget(candidate.webSocketDebuggerUrl)) {
163
+ acceptedPinnedCandidate = candidate;
164
+ return true;
159
165
  }
160
- return false;
161
166
  }
162
- : undefined,
163
- );
167
+ return false;
168
+ }
169
+ return true;
170
+ });
164
171
  } catch (e) {
165
172
  throw coded(
166
173
  new Error(`Cannot reach Metro at ${listUrl}. Is Metro running?\n ${e.message}`),
@@ -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
  }
@@ -26,10 +26,10 @@ function parseArgs(argv) {
26
26
  async function main() {
27
27
  const args = parseArgs(process.argv.slice(2));
28
28
  if (args.help) {
29
- console.log('Usage: launch-metro.cjs --target <path> --port <port> --log <path> --pid-file <path> [--workers <n>] [--clear]');
29
+ console.log('Usage: launch-metro.cjs --target <path> --port <port> --log <path> --pid-file <path> --build-env <path> [--workers <n>] [--clear]');
30
30
  return;
31
31
  }
32
- for (const required of ['target', 'port', 'log', 'pid-file']) {
32
+ for (const required of ['target', 'port', 'log', 'pid-file', 'build-env']) {
33
33
  if (!args[required]) throw new Error(`--${required} is required`);
34
34
  }
35
35
  const target = path.resolve(args.target);
@@ -38,9 +38,9 @@ async function main() {
38
38
  fs.mkdirSync(path.dirname(log), { recursive: true });
39
39
  fs.mkdirSync(path.dirname(pidFile), { recursive: true });
40
40
  const logFd = fs.openSync(log, 'a');
41
- const childArgs = ['expo', 'start', '--port', String(args.port)];
42
- if (args.clear) childArgs.push('--clear');
41
+ const childArgs = ['watch'];
43
42
  const env = { ...process.env };
43
+ env.BASH_ENV = args['build-env'];
44
44
  if (args.workers) env.METRO_MAX_WORKERS = String(args.workers);
45
45
  const child = spawn('yarn', childArgs, {
46
46
  cwd: target,
@@ -59,10 +59,12 @@ async function main() {
59
59
  `${JSON.stringify({
60
60
  schemaVersion: 1,
61
61
  pid: child.pid,
62
+ pgid: child.pid,
62
63
  startedAt: new Date().toISOString(),
63
64
  target,
64
65
  port: Number(args.port),
65
66
  clear: Boolean(args.clear),
67
+ command: `yarn ${childArgs[0]}`,
66
68
  }, null, 2)}\n`,
67
69
  );
68
70
  child.unref();