@deeeed/metamask-harness 0.10.0 → 0.12.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 (72) hide show
  1. package/CHANGELOG.md +35 -0
  2. package/adapters/core/cleanup.sh +0 -0
  3. package/adapters/core/inject.sh +0 -0
  4. package/adapters/extension/cleanup.mjs +0 -0
  5. package/adapters/extension/ensure-browser.sh +0 -0
  6. package/adapters/extension/inject.mjs +0 -0
  7. package/adapters/extension/launch-browser.cjs +0 -0
  8. package/adapters/extension/launch.sh +0 -0
  9. package/adapters/extension/live.sh +0 -0
  10. package/adapters/extension/readiness.mjs +0 -0
  11. package/adapters/extension/reattach.sh +0 -0
  12. package/adapters/extension/refresh-build.sh +0 -0
  13. package/adapters/extension/seed-fixture.sh +0 -0
  14. package/adapters/extension/sidepanel-toggle.sh +0 -0
  15. package/adapters/extension/snapshot-dist.sh +0 -0
  16. package/adapters/extension/start-watch.sh +0 -0
  17. package/adapters/extension/verify.sh +0 -0
  18. package/adapters/extension/wallet-fixture-state.cjs +0 -0
  19. package/adapters/mobile/bridge-runtime/cdp-bridge.cjs +47 -0
  20. package/adapters/mobile/bridge-runtime/console-forwarder.cjs +343 -0
  21. package/adapters/mobile/bridge-runtime/lib/target-discovery.cjs +31 -29
  22. package/adapters/mobile/bridge-runtime/setup-wallet.sh +0 -0
  23. package/adapters/mobile/cleanup.sh +4 -0
  24. package/adapters/mobile/inject.sh +0 -0
  25. package/adapters/mobile/lib/metro-listener.sh +0 -0
  26. package/adapters/mobile/lib/tmux-viewer.sh +0 -0
  27. package/adapters/mobile/open-device.sh +0 -0
  28. package/adapters/mobile/prewarm-bundle.sh +0 -0
  29. package/adapters/mobile/start-metro.sh +110 -36
  30. package/adapters/mobile/stop-metro.sh +21 -0
  31. package/adapters/mobile/verify.sh +0 -0
  32. package/adapters/mobile/wait-for-bridge.sh +15 -1
  33. package/adapters/mobile/yarn-setup.sh +0 -0
  34. package/adapters/shared/activate-repo-node.sh +0 -0
  35. package/adapters/shared/activate-repo-ruby.sh +0 -0
  36. package/adapters/shared/cli-ux.sh +0 -0
  37. package/adapters/shared/ensure-runner-deps.sh +0 -0
  38. package/adapters/shared/harness-path.sh +0 -0
  39. package/adapters/shared/hash-helpers.sh +0 -0
  40. package/adapters/shared/json-field.sh +0 -0
  41. package/adapters/shared/open-debug.mjs +5 -0
  42. package/adapters/shared/open-log-window.sh +0 -0
  43. package/adapters/shared/reap-checkout-metros.sh +0 -0
  44. package/adapters/shared/resolve-farmslot-ports.mjs +0 -0
  45. package/adapters/shared/resolve-farmslot-ports.sh +0 -0
  46. package/adapters/shared/resolve-slot-ports.mjs +0 -0
  47. package/adapters/shared/resolve-slot-ports.sh +0 -0
  48. package/adapters/shared/sync-wallet-fixture.sh +0 -0
  49. package/adapters/shared/tmux-session.sh +0 -0
  50. package/dist/adapters.js +49 -19
  51. package/dist/app-lifecycle.js +72 -0
  52. package/dist/cli-commands.js +1 -1
  53. package/dist/commands/device-target.js +35 -9
  54. package/dist/commands/fixtures.js +13 -1
  55. package/dist/commands/launch/index.js +35 -7
  56. package/dist/commands/run-engine.js +1 -0
  57. package/dist/mm-harness-cli.js +1 -0
  58. package/dist/paths.js +8 -1
  59. package/dist/runner.js +10 -4
  60. package/docs/CLI-SPEC.md +2 -1
  61. package/docs/recipe-libraries.md +19 -0
  62. package/library/actions/mobile/platform/bridge.mjs +29 -7
  63. package/library/actions/mobile/wallet/ensure_unlocked.mjs +66 -8
  64. package/library/manifests/mobile.action-manifest.json +18 -0
  65. package/library/recipes/app-lifecycle-android-smoke.mobile.recipe.json +87 -0
  66. package/library/recipes/perps-performance-background-resume.mobile.recipe.json +72 -0
  67. package/library/recipes/perps-performance-cold-start.mobile.recipe.json +72 -0
  68. package/library/recipes/perps-performance-warm-start.mobile.recipe.json +64 -0
  69. package/library/recipes/perps-performance.mobile.recipe.json +15 -9
  70. package/package.json +3 -3
  71. package/scripts/completions.sh +0 -0
  72. package/scripts/install-completions.sh +0 -0
package/CHANGELOG.md CHANGED
@@ -1,5 +1,40 @@
1
1
  # Changelog
2
2
 
3
+ ## Unreleased
4
+
5
+ ### Fixed
6
+ - Runner construction no longer fails with `Manifest action app.lifecycle has no registered adapter` when a manifest declaring `app.lifecycle` is used with a non-mobile adapter (e.g. `call --adapter core --action-manifest library/manifests/mobile.action-manifest.json`): lifecycle adapter registration is manifest-driven instead of gated on the mobile adapter. Executing the action outside a mobile run still fails explicitly at target resolution.
7
+
8
+ ## 0.12.0 - 2026-07-08
9
+
10
+ ### Added
11
+ - Mobile console-forwarder: restores the device console (incl. DevLogger) in `metro.log` on RN 0.79+/Bridgeless via persistent CDP streaming (`Runtime.consoleAPICalled`) — one session per device JS-runtime page (shared target-discovery ranking, never the native C++ page), buffer replay + persisted last-seen cursor (timestamp + same-stamp texts) deduping across reconnects/app reloads, auto-started by `start-metro`; opt out with `METAMASK_RECIPE_CONSOLE_FORWARD=0`. Grep `[console:` for device lines. Zero-dependency transport (built-in Node 22+ WebSocket, like the CDP bridge client). Yields to interactive React Native DevTools: a session evicted by another debugger with no bridge lock present backs off for 5 minutes instead of evicting the human every discovery tick. Idles at the slow poll cadence while Metro is down; flushes pending lines and dedupe state synchronously on SIGTERM/SIGINT.
12
+
13
+ ### Fixed
14
+ - `app.hud` no longer aborts recipes on `CDP message timeout` / `WebSocket closed` during dev-client lifecycle transitions (0.11.0 regression that failed every warm-start run at `foreground-app`/`ensure-unlocked`). Skips are loud: the reason is written to stderr and recorded in the action result, so a masked real failure remains visible.
15
+ - cdp-bridge takes a runtime lock (`cdp-bridge.lock`) so the console-forwarder yields the single debugger slot during bridge commands — prevents mutual eviction and lost `Runtime.evaluate` replies. The forwarder honors the lock while the pid inside it is alive (`kill -0`), so long commands (wallet setup runs `CDP_TIMEOUT=120000`) are never evicted mid-flight while a crashed/killed bridge releases immediately; the bridge releases on SIGTERM/SIGINT, only unlinks a lock it still owns (last-writer-wins under overlap), and takes no lock for `--help`/unknown commands.
16
+ - `stop-metro` and mobile `cleanup` stop the console-forwarder (recorded pid + checkout-scoped sweep), so no orphan forwarder outlives Metro or fights a later one for the debugger slot.
17
+ - `open-debug` anchors its CDP-eval fallback to the app checkout so the bridge lock lands where the forwarder watches.
18
+
19
+ ## 0.11.0 - 2026-07-07
20
+
21
+ ### Added
22
+ - Mobile recipes can now use the standard outer `app.lifecycle` action for deterministic performance start states without rebuilding (Android background/foreground/terminate/restart, iOS simulator launch/terminate/restart through the shared Farmslot lifecycle adapter).
23
+ - `fixtures set --device <serial|udid|name>` now pins mobile fixture sync to the requested device using the same targeting rules as `run`, `call`, and `doctor`.
24
+ - New lifecycle-controlled measured flows in the packaged library: `app-lifecycle-android-smoke`, `perps-performance-warm-start`, `perps-performance-background-resume`, `perps-performance-cold-start`; performance recipes now keep lifecycle/unlock in `setup[]`/`startState` so measured nodes carry only CUF timings.
25
+
26
+ ### Changed
27
+ - `@farmslot/recipe-harness` dependency raised to `^0.4.0` (ships the `app.lifecycle` adapter — the `FARMSLOT_ROOT` local-source fallback is no longer needed at runtime).
28
+
29
+ ### Fixed
30
+ - `launch ios --device <udid|name>` again accepts a shutdown simulator target: launch preserves the requested simulator identity and lets `open-device.sh` boot it instead of requiring it to appear in the booted-device list first.
31
+ - iOS UDID pins are exported as both `SIM_UDID` and simulator name where available, so downstream simulator tooling can use the stable name while preserving the exact pin.
32
+ - `metamask.wallet.ensure_unlocked` now waits for the pinned agentic bridge target, tolerates transient status drops during the unlocked stability check, and avoids the old full password-unlock fallback for brief bridge gaps.
33
+ - Automatic `app.hud` updates are best-effort only while the mobile bridge target is down during app lifecycle transitions; ordinary HUD bridge failures now fail loudly.
34
+ - Mobile `cdp.target` required checks only pass when the responding target has `agenticPresent === true`, avoiding false positives from non-instrumented React Native targets.
35
+ - Mobile Metro startup is hardened around tmux/PID ownership: stale PID files and dead tmux windows are cleaned before reuse, and Metro survives the launching shell.
36
+ - `wait-for-bridge` now matches the selected Android target with the same boundary-safe device-name rules as device targeting and waits for the selected target, not just any target on the Metro port.
37
+
3
38
  ## 0.10.0 - 2026-07-07
4
39
 
5
40
  ### Added
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
@@ -605,6 +605,50 @@ const COMMANDS = {
605
605
  // Main
606
606
  // ---------------------------------------------------------------------------
607
607
 
608
+ // Bridge-priority lock: the Hermes inspector proxy reliably serves one debugger
609
+ // slot, so the console-forwarder yields it while a bridge command runs (it
610
+ // watches this file and backfills from the runtime's console buffer afterward).
611
+ const BRIDGE_LOCK_FILE = path.join(
612
+ process.env.RECIPE_RUNTIME_DIR || path.join('temp', 'recipe', 'runtime'),
613
+ 'cdp-bridge.lock',
614
+ );
615
+
616
+ // Ownership semantics: last writer wins. Each bridge stamps its own pid; the
617
+ // forwarder honors the lock while the pid IN THE FILE is alive. Overlapping
618
+ // bridge commands therefore keep the slot guarded as long as the most recent
619
+ // writer runs, and an earlier command that outlives it may lose the slot — one
620
+ // bridge command at a time is the supported concurrency.
621
+ let bridgeLockHeld = false;
622
+
623
+ function acquireBridgeLock() {
624
+ try {
625
+ fs.writeFileSync(BRIDGE_LOCK_FILE, String(process.pid));
626
+ bridgeLockHeld = true;
627
+ } catch {
628
+ // Lock is best-effort: a missing runtime dir must not break bridge commands.
629
+ }
630
+ }
631
+
632
+ process.on('exit', () => {
633
+ // Only the current owner removes the lock: a no-lock invocation (--help,
634
+ // unknown command) or a bridge whose lock was overwritten by a later one
635
+ // must not release the slot under the still-running writer.
636
+ if (!bridgeLockHeld) return;
637
+ try {
638
+ if (fs.readFileSync(BRIDGE_LOCK_FILE, 'utf8').trim() === String(process.pid)) {
639
+ fs.unlinkSync(BRIDGE_LOCK_FILE);
640
+ }
641
+ } catch {
642
+ // Already gone.
643
+ }
644
+ });
645
+
646
+ // Signal-killed bridges (status-probe kills timed-out probes SIGTERM→SIGKILL)
647
+ // skip 'exit' unless a handler turns the signal into process.exit — without
648
+ // this the lock strands and mutes console forwarding until staleness lapses.
649
+ process.on('SIGTERM', () => process.exit(143));
650
+ process.on('SIGINT', () => process.exit(130));
651
+
608
652
  async function main() {
609
653
  const args = process.argv.slice(2);
610
654
  const command = args[0];
@@ -657,6 +701,9 @@ Environment:
657
701
  process.exit(1);
658
702
  }
659
703
 
704
+ // Only a real command takes the debugger slot; help/unknown paths above never
705
+ // suppress the console forwarder.
706
+ acquireBridgeLock();
660
707
 
661
708
  const port = loadPort();
662
709
  const timeout = Number.parseInt(process.env.CDP_TIMEOUT || '5000', 10);
@@ -0,0 +1,343 @@
1
+ #!/usr/bin/env node
2
+ // console-forwarder — stream device console lines into metro.log.
3
+ //
4
+ // React Native (Bridgeless) gates its legacy console→Metro forwarding on
5
+ // `console._isPolyfilled` (setUpDeveloperTools.js, T214991636); with the Hermes
6
+ // native console that gate is false, so app logs (incl. DevLogger) never reach
7
+ // Metro's log. The supported contract for log consumption in modern RN is CDP —
8
+ // this process does exactly what React Native DevTools does: hold a persistent
9
+ // debugger session per device page, enable the Runtime domain once, and stream
10
+ // `Runtime.consoleAPICalled` events as they happen.
11
+ //
12
+ // Recovery: on session loss the runtime's console buffer is replayed on the
13
+ // next Runtime.enable, and a per-device cursor (last-seen timestamp + texts at
14
+ // that timestamp, persisted next to the log) dedupes it — lines emitted during
15
+ // a disconnect, an app reload, or a forwarder restart are backfilled once.
16
+ // Dedupe keys on the runtime's console timestamps (fractional-ms doubles), so
17
+ // a device clock stepping backwards can drop lines emitted below the cursor:
18
+ // replay is a recovery path, not a ledger. Target discovery is a cheap HTTP
19
+ // poll against Metro only (never the app runtime): fast while a device is
20
+ // unattached, slow when all sessions are live. Never exits on its own; idles
21
+ // while Metro is down.
22
+ //
23
+ // Usage: node console-forwarder.cjs --port <metroPort> --out <logFile>
24
+
25
+ 'use strict';
26
+
27
+ const fs = require('node:fs');
28
+ const http = require('node:http');
29
+ const path = require('node:path');
30
+ const { rankRuntimeCandidates } = require('./lib/target-discovery.cjs');
31
+
32
+ // Built-in WebSocket (Node 22+), same zero-dependency transport choice as
33
+ // lib/ws-client.cjs — the published package must not depend on `ws`.
34
+ const HANDSHAKE_TIMEOUT_MS = 3000;
35
+
36
+ const DISCOVER_ACTIVE_MS = 1000; // a device is unattached — look for it quickly
37
+ const DISCOVER_STEADY_MS = 10000; // all known targets attached — cheap liveness tick
38
+ const FLUSH_MS = 100;
39
+ const MAX_LINE_CHARS = 4000;
40
+
41
+ function parseArgs(argv) {
42
+ const args = { port: process.env.WATCHER_PORT || '8081', out: null };
43
+ for (let i = 2; i < argv.length; i += 1) {
44
+ if (argv[i] === '--port') args.port = argv[++i];
45
+ else if (argv[i] === '--out') args.out = argv[++i];
46
+ }
47
+ if (!args.out) {
48
+ process.stderr.write('console-forwarder: --out <logFile> is required\n');
49
+ process.exit(2);
50
+ }
51
+ return args;
52
+ }
53
+
54
+ const { port, out } = parseArgs(process.argv);
55
+ const statePath = `${out}.forwarder-state.json`;
56
+
57
+ // Bridge-priority coordination: the inspector proxy reliably serves one
58
+ // debugger slot. cdp-bridge holds this lock while a command runs; we yield the
59
+ // slot immediately and re-attach after a settle window — the runtime's console
60
+ // buffer replay backfills everything missed, so no lines are lost.
61
+ const LOCK_FILE = path.join(path.dirname(out), 'cdp-bridge.lock');
62
+ const LOCK_SETTLE_MS = 1500;
63
+ const LOCK_STALE_MS = 30000; // unreadable lock body: crashed bridge must not block logs forever
64
+ // dev-middleware serves one debugger slot per device; when a debugger we do not
65
+ // coordinate with (React Native DevTools) takes it, re-attaching would evict
66
+ // the human back and start a mutual-eviction storm. Stand down for a long
67
+ // window instead — bridge commands still work (they carry their own lock).
68
+ const FOREIGN_DEBUGGER_BACKOFF_MS = 5 * 60 * 1000;
69
+
70
+ function bridgeLockActive() {
71
+ // The lock body is the bridge pid, so liveness is the real signal: a bridge
72
+ // command may legitimately outlive any fixed mtime window (wallet setup runs
73
+ // with CDP_TIMEOUT=120000). mtime staleness only guards an unreadable body.
74
+ let body;
75
+ try {
76
+ body = fs.readFileSync(LOCK_FILE, 'utf8');
77
+ } catch {
78
+ return false;
79
+ }
80
+ const pid = Number.parseInt(body.trim(), 10);
81
+ if (Number.isInteger(pid) && pid > 0) {
82
+ try {
83
+ process.kill(pid, 0);
84
+ return true;
85
+ } catch (error) {
86
+ // EPERM: the pid is alive but owned by another user — still a live bridge.
87
+ return error.code === 'EPERM';
88
+ }
89
+ }
90
+ try {
91
+ return Date.now() - fs.statSync(LOCK_FILE).mtimeMs < LOCK_STALE_MS;
92
+ } catch {
93
+ return false;
94
+ }
95
+ }
96
+
97
+ function yieldSessions() {
98
+ for (const ws of sessions.values()) {
99
+ try {
100
+ ws.close();
101
+ } catch {}
102
+ }
103
+ sessions.clear();
104
+ }
105
+
106
+ let resumeTimer = null;
107
+ try {
108
+ fs.watch(path.dirname(out), (_event, filename) => {
109
+ if (filename !== path.basename(LOCK_FILE)) return;
110
+ if (bridgeLockActive()) {
111
+ if (resumeTimer) {
112
+ clearTimeout(resumeTimer);
113
+ resumeTimer = null;
114
+ }
115
+ yieldSessions();
116
+ } else if (!resumeTimer) {
117
+ resumeTimer = setTimeout(() => {
118
+ resumeTimer = null;
119
+ discover();
120
+ }, LOCK_SETTLE_MS);
121
+ }
122
+ });
123
+ } catch {
124
+ // fs.watch unavailable: the stale-mtime check in discover() still guards us.
125
+ }
126
+
127
+ /**
128
+ * deviceId -> { ts, seen } dedupe cursor: highest consoleAPICalled timestamp
129
+ * already written, plus the formatted texts already written AT that timestamp.
130
+ * Runtime stamps are fractional-ms doubles, but two logs in one tick share a
131
+ * stamp — a timestamp-only cursor dropped the second on replay. A device clock
132
+ * stepping backwards can still drop lines (ts below the cursor): replay is a
133
+ * recovery path, not a ledger.
134
+ */
135
+ const lastByDevice = new Map();
136
+ try {
137
+ for (const [device, value] of Object.entries(JSON.parse(fs.readFileSync(statePath, 'utf8')))) {
138
+ // Numeric values are state written by a timestamp-only forwarder build.
139
+ lastByDevice.set(
140
+ device,
141
+ typeof value === 'number'
142
+ ? { ts: value, seen: new Set() }
143
+ : { ts: Number(value.ts) || 0, seen: new Set(Array.isArray(value.seen) ? value.seen : []) },
144
+ );
145
+ }
146
+ } catch {}
147
+
148
+ function serializeState() {
149
+ const state = {};
150
+ for (const [device, cursor] of lastByDevice) {
151
+ state[device] = { ts: cursor.ts, seen: [...cursor.seen] };
152
+ }
153
+ return JSON.stringify(state);
154
+ }
155
+
156
+ /** deviceId -> live WebSocket session. */
157
+ const sessions = new Map();
158
+
159
+ /** deviceId -> epoch ms until which a foreign debugger owns the slot. */
160
+ const foreignDebuggerUntil = new Map();
161
+
162
+ /** Buffered lines, flushed together so replay bursts are one write. */
163
+ let pending = [];
164
+ let flushTimer = null;
165
+
166
+ function flush() {
167
+ flushTimer = null;
168
+ if (pending.length === 0) return;
169
+ const lines = pending.join('\n');
170
+ pending = [];
171
+ fs.appendFile(out, `${lines}\n`, () => {});
172
+ fs.writeFile(statePath, serializeState(), () => {});
173
+ }
174
+
175
+ // Signal-path flush: process.exit() cancels queued async I/O, so the SIGTERM/
176
+ // SIGINT handlers must write synchronously or pending lines and the last-seen
177
+ // state are lost on every restart (a stale state file re-duplicates replay).
178
+ function flushSync() {
179
+ if (flushTimer) {
180
+ clearTimeout(flushTimer);
181
+ flushTimer = null;
182
+ }
183
+ const lines = pending.length > 0 ? `${pending.join('\n')}\n` : '';
184
+ pending = [];
185
+ try {
186
+ if (lines) fs.appendFileSync(out, lines);
187
+ } catch {}
188
+ try {
189
+ fs.writeFileSync(statePath, serializeState());
190
+ } catch {}
191
+ }
192
+
193
+ function queueLine(line) {
194
+ pending.push(line);
195
+ if (!flushTimer) flushTimer = setTimeout(flush, FLUSH_MS);
196
+ }
197
+
198
+ function levelLabel(type) {
199
+ const t = String(type || 'log').toUpperCase();
200
+ return t === 'WARNING' ? 'WARN' : t;
201
+ }
202
+
203
+ function formatArgs(args) {
204
+ const text = (args || [])
205
+ .map((a) => (a.value !== undefined ? String(a.value) : (a.description ?? a.type ?? '')))
206
+ .join(' ');
207
+ return text.length > MAX_LINE_CHARS ? `${text.slice(0, MAX_LINE_CHARS)}…` : text;
208
+ }
209
+
210
+ function deviceNameFromTitle(title) {
211
+ const m = /\(([^)]+)\)\s*$/.exec(title || '');
212
+ return m ? m[1] : (title || 'device');
213
+ }
214
+
215
+ function deviceIdFromUrl(wsUrl) {
216
+ const m = /[?&]device=([^&]+)/.exec(wsUrl || '');
217
+ return m ? m[1] : wsUrl;
218
+ }
219
+
220
+ function connect(target) {
221
+ const deviceId = deviceIdFromUrl(target.webSocketDebuggerUrl);
222
+ const name = deviceNameFromTitle(target.title);
223
+ let ws;
224
+ try {
225
+ ws = new WebSocket(target.webSocketDebuggerUrl);
226
+ } catch {
227
+ return;
228
+ }
229
+ sessions.set(deviceId, ws);
230
+ // The built-in WebSocket has no handshake timeout; a hung connect would hold
231
+ // the session slot forever and block every future re-attach for this device.
232
+ const handshakeTimer = setTimeout(() => {
233
+ try {
234
+ ws.close();
235
+ } catch {}
236
+ }, HANDSHAKE_TIMEOUT_MS);
237
+ ws.addEventListener('open', () => {
238
+ clearTimeout(handshakeTimer);
239
+ ws.send(JSON.stringify({ id: 1, method: 'Runtime.enable' }));
240
+ process.stderr.write(`console-forwarder: attached ${name}\n`);
241
+ });
242
+ ws.addEventListener('message', (event) => {
243
+ let msg;
244
+ try {
245
+ msg = JSON.parse(String(event.data));
246
+ } catch {
247
+ return;
248
+ }
249
+ if (msg.method !== 'Runtime.consoleAPICalled') return;
250
+ const text = formatArgs(msg.params.args);
251
+ // dev-middleware emits this NOTE on every debugger attach (i.e. ours). Drop it.
252
+ if (text.includes('unsupported debugging client')) return;
253
+ const ts = msg.params.timestamp || Date.now();
254
+ const cursor = lastByDevice.get(deviceId);
255
+ if (cursor && ts < cursor.ts) return;
256
+ if (cursor && ts === cursor.ts) {
257
+ if (cursor.seen.has(text)) return;
258
+ cursor.seen.add(text);
259
+ } else {
260
+ lastByDevice.set(deviceId, { ts, seen: new Set([text]) });
261
+ }
262
+ const time = new Date(ts).toISOString().slice(11, 23);
263
+ queueLine(` ${levelLabel(msg.params.type)} ${time} [console:${name}] ${text}`);
264
+ });
265
+ const drop = () => {
266
+ clearTimeout(handshakeTimer);
267
+ if (sessions.get(deviceId) === ws) {
268
+ sessions.delete(deviceId);
269
+ process.stderr.write(`console-forwarder: detached ${name}; will re-attach\n`);
270
+ }
271
+ };
272
+ ws.addEventListener('close', (event) => {
273
+ // dev-middleware closes the previous debugger with NEW_DEBUGGER_OPENED when
274
+ // another one attaches. With no bridge lock present that debugger is a
275
+ // human's DevTools session — back off long instead of evicting them back.
276
+ const why = event && event.reason ? String(event.reason) : '';
277
+ if (why.includes('NEW_DEBUGGER_OPENED') && !bridgeLockActive()) {
278
+ foreignDebuggerUntil.set(deviceId, Date.now() + FOREIGN_DEBUGGER_BACKOFF_MS);
279
+ process.stderr.write(
280
+ `console-forwarder: another debugger took ${name}; standing down for ${FOREIGN_DEBUGGER_BACKOFF_MS / 60000} min\n`,
281
+ );
282
+ }
283
+ drop();
284
+ });
285
+ ws.addEventListener('error', drop);
286
+ }
287
+
288
+ function discover() {
289
+ if (bridgeLockActive()) {
290
+ schedule(DISCOVER_ACTIVE_MS);
291
+ return;
292
+ }
293
+ http
294
+ .get({ host: 'localhost', port, path: '/json/list', timeout: 3000 }, (res) => {
295
+ let body = '';
296
+ res.on('data', (c) => (body += c));
297
+ res.on('end', () => {
298
+ let targets;
299
+ try {
300
+ targets = JSON.parse(body);
301
+ } catch {
302
+ schedule(DISCOVER_ACTIVE_MS);
303
+ return;
304
+ }
305
+ // Same candidate filter + JS-runtime-first ranking the bridge uses:
306
+ // devices expose multiple pages (page 1 = native C++ runtime) and the
307
+ // first ranked page per device is its JS runtime — attaching to the
308
+ // native page would stream nothing and block the right one.
309
+ let unattached = false;
310
+ const picked = new Set();
311
+ for (const t of rankRuntimeCandidates(targets)) {
312
+ const deviceId = deviceIdFromUrl(t.webSocketDebuggerUrl);
313
+ if (picked.has(deviceId)) continue;
314
+ picked.add(deviceId);
315
+ if (sessions.has(deviceId)) continue;
316
+ if ((foreignDebuggerUntil.get(deviceId) || 0) > Date.now()) continue;
317
+ foreignDebuggerUntil.delete(deviceId);
318
+ unattached = true;
319
+ connect(t);
320
+ }
321
+ schedule(unattached ? DISCOVER_ACTIVE_MS : DISCOVER_STEADY_MS);
322
+ });
323
+ })
324
+ .on('error', () => schedule(DISCOVER_STEADY_MS));
325
+ }
326
+
327
+ let discoverTimer = null;
328
+ function schedule(ms) {
329
+ if (discoverTimer) clearTimeout(discoverTimer);
330
+ discoverTimer = setTimeout(discover, ms);
331
+ }
332
+
333
+ process.on('SIGTERM', () => {
334
+ flushSync();
335
+ process.exit(0);
336
+ });
337
+ process.on('SIGINT', () => {
338
+ flushSync();
339
+ process.exit(0);
340
+ });
341
+
342
+ process.stderr.write(`console-forwarder: streaming Metro :${port} → ${out}\n`);
343
+ discover();
@@ -78,6 +78,30 @@ async function probeTargetDetailed(wsUrl) {
78
78
  }
79
79
  }
80
80
 
81
+ /**
82
+ * Filter Metro /json/list targets to debugger-capable RN/Hermes pages and rank
83
+ * them JS-runtime-first. Pre-RN-0.81 titles say "React Native"/"Hermes";
84
+ * RN 0.81+ Bridgeless titles are bundle-id-only (e.g. "io.metamask.MetaMask
85
+ * (mm-5)") with the runtime kind in `description`. Each device exposes
86
+ * multiple pages — page 1 is the native C++ runtime; the JS runtime (console,
87
+ * __AGENTIC__) has a higher page number, so after the sort the first candidate
88
+ * per device is its JS runtime.
89
+ */
90
+ function rankRuntimeCandidates(targets) {
91
+ const candidates = (Array.isArray(targets) ? targets : []).filter(
92
+ (t) =>
93
+ t.webSocketDebuggerUrl &&
94
+ ((t.title && (/react/i.test(t.title) || /hermes/i.test(t.title))) ||
95
+ /bridgeless|hermes/i.test(t.description || '')),
96
+ );
97
+ candidates.sort((a, b) => {
98
+ const aPage = Number.parseInt((a.id || '').split('-').pop() || '0', 10);
99
+ const bPage = Number.parseInt((b.id || '').split('-').pop() || '0', 10);
100
+ return bPage - aPage;
101
+ });
102
+ return candidates;
103
+ }
104
+
81
105
  /**
82
106
  * Discover the Hermes CDP WebSocket URL from Metro's /json/list endpoint.
83
107
  *
@@ -103,16 +127,7 @@ async function discoverTarget(port) {
103
127
  }
104
128
 
105
129
  // Filter to React Native / Hermes targets with a WebSocket URL
106
- let candidates = targets.filter(
107
- (t) =>
108
- t.webSocketDebuggerUrl &&
109
- // Pre-RN-0.81 titles say "React Native"/"Hermes". RN 0.81+ Bridgeless
110
- // titles are bundle-id-only (e.g. "io.metamask.MetaMask (mm-5)") and
111
- // the runtime kind is in `description` instead.
112
- ((t.title &&
113
- (/react/i.test(t.title) || /hermes/i.test(t.title))) ||
114
- /bridgeless|hermes/i.test(t.description || '')),
115
- );
130
+ let candidates = rankRuntimeCandidates(targets);
116
131
 
117
132
  // Android pin identities (loaded before the simulator filter so an explicit
118
133
  // android pin can take precedence over an ambient simulator name):
@@ -216,7 +231,10 @@ async function discoverTarget(port) {
216
231
  );
217
232
  }
218
233
 
219
- // Sort by page number descending (JS runtime has higher page number than C++ native)
234
+ // Sort by page number descending (JS runtime has higher page number than C++
235
+ // native). rankRuntimeCandidates output arrives pre-sorted; this re-sort is
236
+ // load-bearing only when the raw targets.filter() fallback above repopulated
237
+ // the candidate list.
220
238
  candidates.sort((a, b) => {
221
239
  const aPage = Number.parseInt((a.id || '').split('-').pop() || '0', 10);
222
240
  const bPage = Number.parseInt((b.id || '').split('-').pop() || '0', 10);
@@ -250,23 +268,7 @@ async function discoverAllTargets(port) {
250
268
  );
251
269
  }
252
270
 
253
- const candidates = (targets || []).filter(
254
- (t) =>
255
- t.webSocketDebuggerUrl &&
256
- // Pre-RN-0.81 titles say "React Native"/"Hermes". RN 0.81+ Bridgeless
257
- // titles are bundle-id-only (e.g. "io.metamask.MetaMask (mm-5)") and
258
- // the runtime kind is in `description` instead.
259
- ((t.title &&
260
- (/react/i.test(t.title) || /hermes/i.test(t.title))) ||
261
- /bridgeless|hermes/i.test(t.description || '')),
262
- );
263
-
264
- // Sort by page number descending (JS runtime has higher page number)
265
- candidates.sort((a, b) => {
266
- const aPage = Number.parseInt((a.id || '').split('-').pop() || '0', 10);
267
- const bPage = Number.parseInt((b.id || '').split('-').pop() || '0', 10);
268
- return bPage - aPage;
269
- });
271
+ const candidates = rankRuntimeCandidates(targets);
270
272
 
271
273
  // Group by deviceName, probe each to find the JS runtime target. Prefer the
272
274
  // device's __AGENTIC__-bearing target; when a device has none, keep its first
@@ -297,4 +299,4 @@ async function discoverAllTargets(port) {
297
299
  return results;
298
300
  }
299
301
 
300
- module.exports = { discoverTarget, discoverAllTargets };
302
+ module.exports = { discoverTarget, discoverAllTargets, rankRuntimeCandidates };
File without changes
@@ -45,6 +45,10 @@ if ! command -v harness_root >/dev/null 2>&1; then
45
45
  exit 1
46
46
  fi
47
47
  HARNESS_DIR="$(harness_dir "$TARGET" mobile)"
48
+
49
+ # A console-forwarder left running would keep polling for this checkout's Metro
50
+ # after cleanup orphans it; stop any forwarder scoped to this checkout first.
51
+ pkill -f "console-forwarder.cjs --port .* --out $TARGET/" 2>/dev/null || true
48
52
  if GIT_BACKUP_PATH="$(git -C "$TARGET" rev-parse --git-path recipe-harness/mobile/backup 2>/dev/null)"; then
49
53
  case "$GIT_BACKUP_PATH" in
50
54
  /*) BACKUP_DIR="$GIT_BACKUP_PATH" ;;
File without changes
File without changes
File without changes
File without changes
File without changes