@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.
- package/CHANGELOG.md +35 -0
- package/adapters/core/cleanup.sh +0 -0
- package/adapters/core/inject.sh +0 -0
- package/adapters/extension/cleanup.mjs +0 -0
- package/adapters/extension/ensure-browser.sh +0 -0
- package/adapters/extension/inject.mjs +0 -0
- package/adapters/extension/launch-browser.cjs +0 -0
- package/adapters/extension/launch.sh +0 -0
- package/adapters/extension/live.sh +0 -0
- package/adapters/extension/readiness.mjs +0 -0
- package/adapters/extension/reattach.sh +0 -0
- package/adapters/extension/refresh-build.sh +0 -0
- package/adapters/extension/seed-fixture.sh +0 -0
- package/adapters/extension/sidepanel-toggle.sh +0 -0
- package/adapters/extension/snapshot-dist.sh +0 -0
- package/adapters/extension/start-watch.sh +0 -0
- package/adapters/extension/verify.sh +0 -0
- package/adapters/extension/wallet-fixture-state.cjs +0 -0
- package/adapters/mobile/bridge-runtime/cdp-bridge.cjs +47 -0
- package/adapters/mobile/bridge-runtime/console-forwarder.cjs +343 -0
- package/adapters/mobile/bridge-runtime/lib/target-discovery.cjs +31 -29
- package/adapters/mobile/bridge-runtime/setup-wallet.sh +0 -0
- package/adapters/mobile/cleanup.sh +4 -0
- package/adapters/mobile/inject.sh +0 -0
- package/adapters/mobile/lib/metro-listener.sh +0 -0
- package/adapters/mobile/lib/tmux-viewer.sh +0 -0
- package/adapters/mobile/open-device.sh +0 -0
- package/adapters/mobile/prewarm-bundle.sh +0 -0
- package/adapters/mobile/start-metro.sh +110 -36
- package/adapters/mobile/stop-metro.sh +21 -0
- package/adapters/mobile/verify.sh +0 -0
- package/adapters/mobile/wait-for-bridge.sh +15 -1
- package/adapters/mobile/yarn-setup.sh +0 -0
- package/adapters/shared/activate-repo-node.sh +0 -0
- package/adapters/shared/activate-repo-ruby.sh +0 -0
- package/adapters/shared/cli-ux.sh +0 -0
- package/adapters/shared/ensure-runner-deps.sh +0 -0
- package/adapters/shared/harness-path.sh +0 -0
- package/adapters/shared/hash-helpers.sh +0 -0
- package/adapters/shared/json-field.sh +0 -0
- package/adapters/shared/open-debug.mjs +5 -0
- package/adapters/shared/open-log-window.sh +0 -0
- package/adapters/shared/reap-checkout-metros.sh +0 -0
- package/adapters/shared/resolve-farmslot-ports.mjs +0 -0
- package/adapters/shared/resolve-farmslot-ports.sh +0 -0
- package/adapters/shared/resolve-slot-ports.mjs +0 -0
- package/adapters/shared/resolve-slot-ports.sh +0 -0
- package/adapters/shared/sync-wallet-fixture.sh +0 -0
- package/adapters/shared/tmux-session.sh +0 -0
- package/dist/adapters.js +49 -19
- package/dist/app-lifecycle.js +72 -0
- package/dist/cli-commands.js +1 -1
- package/dist/commands/device-target.js +35 -9
- package/dist/commands/fixtures.js +13 -1
- package/dist/commands/launch/index.js +35 -7
- package/dist/commands/run-engine.js +1 -0
- package/dist/mm-harness-cli.js +1 -0
- package/dist/paths.js +8 -1
- package/dist/runner.js +10 -4
- package/docs/CLI-SPEC.md +2 -1
- package/docs/recipe-libraries.md +19 -0
- package/library/actions/mobile/platform/bridge.mjs +29 -7
- package/library/actions/mobile/wallet/ensure_unlocked.mjs +66 -8
- package/library/manifests/mobile.action-manifest.json +18 -0
- package/library/recipes/app-lifecycle-android-smoke.mobile.recipe.json +87 -0
- package/library/recipes/perps-performance-background-resume.mobile.recipe.json +72 -0
- package/library/recipes/perps-performance-cold-start.mobile.recipe.json +72 -0
- package/library/recipes/perps-performance-warm-start.mobile.recipe.json +64 -0
- package/library/recipes/perps-performance.mobile.recipe.json +15 -9
- package/package.json +3 -3
- package/scripts/completions.sh +0 -0
- 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
|
package/adapters/core/cleanup.sh
CHANGED
|
File without changes
|
package/adapters/core/inject.sh
CHANGED
|
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
|
|
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++
|
|
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
|
|
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
|