@deeeed/metamask-harness 0.11.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 (54) hide show
  1. package/CHANGELOG.md +16 -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 +30 -0
  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 +0 -0
  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 +9 -2
  51. package/dist/runner.js +2 -2
  52. package/package.json +2 -2
  53. package/scripts/completions.sh +0 -0
  54. package/scripts/install-completions.sh +0 -0
package/CHANGELOG.md CHANGED
@@ -1,5 +1,21 @@
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
+
3
19
  ## 0.11.0 - 2026-07-07
4
20
 
5
21
  ### 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
@@ -77,6 +77,34 @@ default_metro_workers() {
77
77
  # shellcheck disable=SC1091
78
78
  . "$SCRIPT_DIR/lib/tmux-viewer.sh"
79
79
 
80
+ # Bridgeless RN gates console→Metro forwarding on console._isPolyfilled, which is
81
+ # false with the Hermes native console — device logs (incl. DevLogger) never reach
82
+ # metro.log. The forwarder attaches over CDP and appends them back. Kill switch:
83
+ # METAMASK_RECIPE_CONSOLE_FORWARD=0.
84
+ start_console_forwarder() {
85
+ local forwarder="$SCRIPT_DIR/bridge-runtime/console-forwarder.cjs"
86
+ local fwd_pid_file="$LOG_DIR/console-forwarder.pid"
87
+ [ "${METAMASK_RECIPE_CONSOLE_FORWARD:-1}" != "0" ] || return 0
88
+ { [ -f "$forwarder" ] && command -v node >/dev/null 2>&1; } || return 0
89
+ if [ -f "$fwd_pid_file" ]; then
90
+ local old_fwd
91
+ old_fwd="$(cat "$fwd_pid_file" 2>/dev/null || true)"
92
+ # A crash can leave a stale pid file and the OS can recycle the pid for an
93
+ # unrelated process — only kill when the command line is really ours.
94
+ if [ -n "$old_fwd" ]; then
95
+ case "$(ps -p "$old_fwd" -o command= 2>/dev/null)" in
96
+ *console-forwarder.cjs*) kill "$old_fwd" 2>/dev/null ;;
97
+ esac
98
+ fi
99
+ rm -f "$fwd_pid_file"
100
+ fi
101
+ nohup node "$forwarder" --port "$PORT" --out "$LOG_FILE" \
102
+ </dev/null >> "$LOG_DIR/console-forwarder.err" 2>&1 &
103
+ echo "$!" > "$fwd_pid_file"
104
+ disown 2>/dev/null || true
105
+ printf 'Device console → metro.log (CDP forwarder pid %s)\n' "$(cat "$fwd_pid_file")" >&2
106
+ }
107
+
80
108
  write_metro_runner() {
81
109
  local runner="$1"
82
110
  local clear_flag=""
@@ -137,6 +165,7 @@ if metro_ready; then
137
165
  rm -f "$PID_FILE"
138
166
  elif metro_listener_valid; then
139
167
  printf 'Metro already running and valid on port %s\n' "$PORT" >&2
168
+ start_console_forwarder
140
169
  exit 0
141
170
  else
142
171
  stop_metro_listener || exit 1
@@ -225,6 +254,7 @@ if [ "$READY" = true ]; then
225
254
  first_ready_pid="${ready_pids%% *}"
226
255
  [ -z "$first_ready_pid" ] || printf '%s\n' "$first_ready_pid" > "$PID_FILE"
227
256
  printf 'Metro ready on port %s\n' "$PORT" >&2
257
+ start_console_forwarder
228
258
  printf ' Next: mm-harness logs (tail Metro)\n' >&2
229
259
  exit 0
230
260
  fi
@@ -62,6 +62,27 @@ fi
62
62
  set +e
63
63
  rm -f "$PID_FILE"
64
64
 
65
+ # start-metro runs a per-checkout console-forwarder holding the device debugger
66
+ # slot; Metro going down must take it too, or the orphan keeps polling and later
67
+ # fights a fresh forwarder over the slot. Kill the pid this LOG_DIR recorded,
68
+ # then sweep forwarders started under a different RECIPE_RUNTIME_DIR of this
69
+ # same checkout (their pid files live elsewhere; match on the --out path).
70
+ FWD_PID_FILE="$LOG_DIR/console-forwarder.pid"
71
+ fwd_pid="$(cat "$FWD_PID_FILE" 2>/dev/null || true)"
72
+ # A crash can leave a stale pid file and the OS can recycle the pid for an
73
+ # unrelated process — only kill when the command line is really ours.
74
+ if [ -n "$fwd_pid" ]; then
75
+ case "$(ps -p "$fwd_pid" -o command= 2>/dev/null)" in
76
+ *console-forwarder.cjs*)
77
+ if kill "$fwd_pid" 2>/dev/null; then
78
+ printf 'Stopped console forwarder (pid %s)\n' "$fwd_pid" >&2
79
+ fi
80
+ ;;
81
+ esac
82
+ fi
83
+ rm -f "$FWD_PID_FILE"
84
+ pkill -f "console-forwarder.cjs --port .* --out $TARGET/" 2>/dev/null
85
+
65
86
  # Port-scoped stop above misses bundlers a prior launch left on a DIFFERENT port
66
87
  # (port drift / missing pid file). Sweep every Metro bound to this checkout so
67
88
  # stop fully cleans up — the leak that stacked bundlers across relaunches.
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
@@ -126,6 +126,11 @@ async function tryDevSettingsCdpFallback(numericPort, normalizedAction) {
126
126
  : 'NativeModules.DevSettings.openDebugger()';
127
127
  const result = spawnSync(process.execPath, [bridgePath, 'eval', expression], {
128
128
  encoding: 'utf8',
129
+ // cdp-bridge resolves its debugger-slot lock (RECIPE_RUNTIME_DIR) against
130
+ // cwd; anchor it to the app checkout so the console forwarder sees the
131
+ // yield — APP_ROOT when a caller exports it, else the cwd mm-harness debug
132
+ // already set to the target.
133
+ cwd: process.env.APP_ROOT || process.cwd(),
129
134
  env: { ...process.env, WATCHER_PORT: String(numericPort) },
130
135
  timeout: 15000,
131
136
  });
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
package/dist/adapters.js CHANGED
@@ -334,6 +334,8 @@ async function handleMobileHud(payload, context) {
334
334
  function mobileHudSkippedOrThrow(error, extra = {}) {
335
335
  const reason = traceText(error);
336
336
  if (isMobileHudLifecycleSkip(reason)) {
337
+ process.stderr.write(`app.hud skipped (bridge target down or transitioning): ${reason}
338
+ `);
337
339
  return {
338
340
  hud: false,
339
341
  skipped: true,
@@ -352,7 +354,11 @@ function isMobileHudLifecycleSkip(reason) {
352
354
  "ECONNREFUSED",
353
355
  "timed out",
354
356
  "Timed out",
355
- "No React Native bridge target"
357
+ "No React Native bridge target",
358
+ // Dev-client foreground/reload kills the Hermes page mid-flight: pending
359
+ // CDP calls surface as a message timeout or a closed socket.
360
+ "CDP message timeout",
361
+ "WebSocket closed"
356
362
  ].some((needle) => reason.includes(needle));
357
363
  }
358
364
  function animatedFlag(payload) {
@@ -504,5 +510,6 @@ function targetProbeUrl(platform, targetPort) {
504
510
  }
505
511
  export {
506
512
  createMetaMaskAdapters,
507
- createMetaMaskUiTransport
513
+ createMetaMaskUiTransport,
514
+ isMobileHudLifecycleSkip
508
515
  };
package/dist/runner.js CHANGED
@@ -40,10 +40,10 @@ async function createMetaMaskRunner(adapter, actionManifest, options = {}) {
40
40
  })
41
41
  });
42
42
  const existing = new Set([...core, ...ui].map((entry) => entry.action));
43
- const lifecycle = adapter === "mobile" ? createAppLifecycleAdapters({
43
+ const lifecycle = createAppLifecycleAdapters({
44
44
  actions,
45
45
  targetProvider: { resolveTarget: resolveMetaMaskMobileLifecycleTarget }
46
- }) : [];
46
+ });
47
47
  const custom = createMetaMaskAdapters(adapter).filter(
48
48
  (entry) => declaredActions.has(entry.action) && !existing.has(entry.action) && !lifecycle.some((lifecycleEntry) => lifecycleEntry.action === entry.action)
49
49
  );
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@deeeed/metamask-harness",
3
- "version": "0.11.0",
3
+ "version": "0.12.0",
4
4
  "type": "module",
5
5
  "bin": {
6
6
  "mm-harness": "bin/mm-harness"
@@ -69,4 +69,4 @@
69
69
  "url": "https://github.com/MetaMask/experimental-metamask-harness/issues"
70
70
  },
71
71
  "homepage": "https://github.com/MetaMask/experimental-metamask-harness#readme"
72
- }
72
+ }
File without changes
File without changes