@deeeed/metamask-harness 0.11.0 → 0.13.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 +24 -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 +63 -0
- package/adapters/mobile/bridge-runtime/console-forwarder.cjs +341 -0
- package/adapters/mobile/bridge-runtime/lib/bridge-errors.cjs +106 -0
- package/adapters/mobile/bridge-runtime/lib/console-format.cjs +53 -0
- package/adapters/mobile/bridge-runtime/lib/match-bridge-target.cjs +82 -0
- package/adapters/mobile/bridge-runtime/lib/target-discovery.cjs +48 -42
- package/adapters/mobile/bridge-runtime/lib/ws-client.cjs +10 -5
- 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 +37 -0
- package/adapters/mobile/stop-metro.sh +21 -0
- package/adapters/mobile/verify.sh +0 -0
- package/adapters/mobile/wait-for-bridge.sh +41 -23
- 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/mobile/prepare.js +23 -2
- package/dist/adapters/mobile/runtime-decision.js +2 -1
- package/dist/adapters.js +39 -9
- package/dist/commands/call.js +80 -1
- package/dist/commands/manifest.js +2 -0
- package/dist/commands/run-engine.js +9 -1
- package/dist/mm-harness-cli.js +12 -0
- package/dist/runner.js +2 -2
- package/library/actions/mobile/platform/bridge.mjs +35 -4
- package/package.json +2 -2
- package/scripts/completions.sh +0 -0
- package/scripts/install-completions.sh +0 -0
package/CHANGELOG.md
CHANGED
|
@@ -1,5 +1,29 @@
|
|
|
1
1
|
# Changelog
|
|
2
2
|
|
|
3
|
+
## 0.13.0 - 2026-07-08
|
|
4
|
+
|
|
5
|
+
### Added
|
|
6
|
+
- Typed cdp-bridge failure codes (`NO_TARGET`, `CDP_TIMEOUT`, `WS_CLOSED`, `METRO_UNREACHABLE`): the bridge classifies a failure at its source and reports it three ways a caller can recover — an `ERROR[<CODE>]:` stderr marker, a code-specific exit status (10–13), and a `Next:` teaching line for the caller's actual situation. `bridge.mjs` attaches the code to the error it throws, and `app.hud` lifecycle-skip classification (`isMobileHudLifecycleSkip`) branches on the code first, keeping the substring needles only as a fallback for output from a bridge that predates the codes. The needle fallback is now case-insensitive, closing a gap where target-discovery's `Pinned Android device …` (capital A) missed a lowercase needle.
|
|
7
|
+
- Console-forwarder now expands object/array console arguments in `metro.log` from the Hermes-supplied inline `preview` (e.g. `{ symbol: "BTC", px: 42 }` instead of the literal `Object`), bounded one level deep and rendered synchronously so the stream never blocks; objects without a preview degrade to their description.
|
|
8
|
+
- `mm-harness call <action> --help` now renders the named action's own field schema (name, type, required, description, example — from the action manifest) above the generic call flags, instead of printing only the generic call help. Fuzzy short-name resolves like `call` (an ambiguous prefix shows every match); an unresolvable name falls back to the generic help plus a pointer to `mm-harness actions`. `run <recipe> --help` is unchanged (recipe-specific help is a separate, larger surface — recipes are not in the action manifest).
|
|
9
|
+
|
|
10
|
+
### Fixed
|
|
11
|
+
- 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.
|
|
12
|
+
- Mobile run teardown always clears any HUD step left painted on-device, so a failed run no longer strands a FAIL banner for the next run. Best-effort and bounded: a down/transitioning bridge simply has nothing to clear and never masks the run's real outcome.
|
|
13
|
+
- start-metro quick-launch now applies the dev Sentry DSN remap (`MM_SENTRY_DSN` defaults to `MM_SENTRY_DSN_DEV` when unset) at both launch paths. The quick-launch runs `expo start` directly and bypasses `scripts/build.sh`, which normally performs this remap, so without it Sentry never initialized in quick-launched dev clients.
|
|
14
|
+
- `launch ios|android` no longer claims `app + bridge ready` when the app is not actually up for the requested platform. On a dual-platform slot (one iOS simulator + one Android device sharing a Metro), a "ready" verdict could be reached from a shared successful bundle plus a cached OS process, and an answering bridge target belonging to the OTHER platform satisfied the claim — so `launch android` printed ✓ while nothing ran on the Android device. The quick-relaunch path now confirms a bridge target bound to the REQUESTED platform before claiming ready: the platform is passed explicitly to `wait-for-bridge` (`--platform ios|android`), so a slot-injected `IOS_SIMULATOR`/`ADB_SERIAL` for the other platform can no longer decide the match. The boundary-safe platform/device matcher is now a single shared module (`bridge-runtime/lib/match-bridge-target.cjs`) used by both `wait-for-bridge` and the confirm. If no matched target answers it launches the app for real, and if the platform-matched target still cannot be brought up it fails loudly. `wait-for-bridge` timeouts now name what was requested vs what answered.
|
|
15
|
+
|
|
16
|
+
## 0.12.0 - 2026-07-08
|
|
17
|
+
|
|
18
|
+
### Added
|
|
19
|
+
- 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.
|
|
20
|
+
|
|
21
|
+
### Fixed
|
|
22
|
+
- `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.
|
|
23
|
+
- 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.
|
|
24
|
+
- `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.
|
|
25
|
+
- `open-debug` anchors its CDP-eval fallback to the app checkout so the bridge lock lands where the forwarder watches.
|
|
26
|
+
|
|
3
27
|
## 0.11.0 - 2026-07-07
|
|
4
28
|
|
|
5
29
|
### 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
|
|
@@ -21,6 +21,12 @@ const path = require('node:path');
|
|
|
21
21
|
const { loadPort } = require('./lib/config.cjs');
|
|
22
22
|
const { discoverTarget } = require('./lib/target-discovery.cjs');
|
|
23
23
|
const { createWSClient } = require('./lib/ws-client.cjs');
|
|
24
|
+
const {
|
|
25
|
+
EXIT_CODE_BY_ERROR_CODE,
|
|
26
|
+
TEACHING_BY_ERROR_CODE,
|
|
27
|
+
classifyBridgeErrorMessage,
|
|
28
|
+
formatErrorMarker,
|
|
29
|
+
} = require('./lib/bridge-errors.cjs');
|
|
24
30
|
const { cdpEval, cdpEvalAsync } = require('./lib/cdp-eval.cjs');
|
|
25
31
|
const { buildArmSnippet, buildCollectSnippet } = require('./lib/issue-capture.cjs');
|
|
26
32
|
|
|
@@ -605,6 +611,50 @@ const COMMANDS = {
|
|
|
605
611
|
// Main
|
|
606
612
|
// ---------------------------------------------------------------------------
|
|
607
613
|
|
|
614
|
+
// Bridge-priority lock: the Hermes inspector proxy reliably serves one debugger
|
|
615
|
+
// slot, so the console-forwarder yields it while a bridge command runs (it
|
|
616
|
+
// watches this file and backfills from the runtime's console buffer afterward).
|
|
617
|
+
const BRIDGE_LOCK_FILE = path.join(
|
|
618
|
+
process.env.RECIPE_RUNTIME_DIR || path.join('temp', 'recipe', 'runtime'),
|
|
619
|
+
'cdp-bridge.lock',
|
|
620
|
+
);
|
|
621
|
+
|
|
622
|
+
// Ownership semantics: last writer wins. Each bridge stamps its own pid; the
|
|
623
|
+
// forwarder honors the lock while the pid IN THE FILE is alive. Overlapping
|
|
624
|
+
// bridge commands therefore keep the slot guarded as long as the most recent
|
|
625
|
+
// writer runs, and an earlier command that outlives it may lose the slot — one
|
|
626
|
+
// bridge command at a time is the supported concurrency.
|
|
627
|
+
let bridgeLockHeld = false;
|
|
628
|
+
|
|
629
|
+
function acquireBridgeLock() {
|
|
630
|
+
try {
|
|
631
|
+
fs.writeFileSync(BRIDGE_LOCK_FILE, String(process.pid));
|
|
632
|
+
bridgeLockHeld = true;
|
|
633
|
+
} catch {
|
|
634
|
+
// Lock is best-effort: a missing runtime dir must not break bridge commands.
|
|
635
|
+
}
|
|
636
|
+
}
|
|
637
|
+
|
|
638
|
+
process.on('exit', () => {
|
|
639
|
+
// Only the current owner removes the lock: a no-lock invocation (--help,
|
|
640
|
+
// unknown command) or a bridge whose lock was overwritten by a later one
|
|
641
|
+
// must not release the slot under the still-running writer.
|
|
642
|
+
if (!bridgeLockHeld) return;
|
|
643
|
+
try {
|
|
644
|
+
if (fs.readFileSync(BRIDGE_LOCK_FILE, 'utf8').trim() === String(process.pid)) {
|
|
645
|
+
fs.unlinkSync(BRIDGE_LOCK_FILE);
|
|
646
|
+
}
|
|
647
|
+
} catch {
|
|
648
|
+
// Already gone.
|
|
649
|
+
}
|
|
650
|
+
});
|
|
651
|
+
|
|
652
|
+
// Signal-killed bridges (status-probe kills timed-out probes SIGTERM→SIGKILL)
|
|
653
|
+
// skip 'exit' unless a handler turns the signal into process.exit — without
|
|
654
|
+
// this the lock strands and mutes console forwarding until staleness lapses.
|
|
655
|
+
process.on('SIGTERM', () => process.exit(143));
|
|
656
|
+
process.on('SIGINT', () => process.exit(130));
|
|
657
|
+
|
|
608
658
|
async function main() {
|
|
609
659
|
const args = process.argv.slice(2);
|
|
610
660
|
const command = args[0];
|
|
@@ -657,6 +707,9 @@ Environment:
|
|
|
657
707
|
process.exit(1);
|
|
658
708
|
}
|
|
659
709
|
|
|
710
|
+
// Only a real command takes the debugger slot; help/unknown paths above never
|
|
711
|
+
// suppress the console forwarder.
|
|
712
|
+
acquireBridgeLock();
|
|
660
713
|
|
|
661
714
|
const port = loadPort();
|
|
662
715
|
const timeout = Number.parseInt(process.env.CDP_TIMEOUT || '5000', 10);
|
|
@@ -699,6 +752,16 @@ Environment:
|
|
|
699
752
|
}
|
|
700
753
|
|
|
701
754
|
main().catch((err) => {
|
|
755
|
+
// Typed failure: a code stamped at the throw site wins; otherwise classify the
|
|
756
|
+
// message here (still at the bridge, not by a needle far away in adapters.ts).
|
|
757
|
+
// The marker + teaching go to stderr and the exit code carries the class, so a
|
|
758
|
+
// caller recovers the code from either channel.
|
|
759
|
+
const code = err && err.code ? err.code : classifyBridgeErrorMessage(err && err.message);
|
|
760
|
+
if (code) {
|
|
761
|
+
console.error(formatErrorMarker(code, err.message));
|
|
762
|
+
if (TEACHING_BY_ERROR_CODE[code]) console.error(TEACHING_BY_ERROR_CODE[code]);
|
|
763
|
+
process.exit(EXIT_CODE_BY_ERROR_CODE[code] || 1);
|
|
764
|
+
}
|
|
702
765
|
console.error(`ERROR: ${err.message}`);
|
|
703
766
|
process.exit(1);
|
|
704
767
|
});
|
|
@@ -0,0 +1,341 @@
|
|
|
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
|
+
const { formatArgs: formatConsoleArgs } = require('./lib/console-format.cjs');
|
|
32
|
+
|
|
33
|
+
// Built-in WebSocket (Node 22+), same zero-dependency transport choice as
|
|
34
|
+
// lib/ws-client.cjs — the published package must not depend on `ws`.
|
|
35
|
+
const HANDSHAKE_TIMEOUT_MS = 3000;
|
|
36
|
+
|
|
37
|
+
const DISCOVER_ACTIVE_MS = 1000; // a device is unattached — look for it quickly
|
|
38
|
+
const DISCOVER_STEADY_MS = 10000; // all known targets attached — cheap liveness tick
|
|
39
|
+
const FLUSH_MS = 100;
|
|
40
|
+
const MAX_LINE_CHARS = 4000;
|
|
41
|
+
|
|
42
|
+
function parseArgs(argv) {
|
|
43
|
+
const args = { port: process.env.WATCHER_PORT || '8081', out: null };
|
|
44
|
+
for (let i = 2; i < argv.length; i += 1) {
|
|
45
|
+
if (argv[i] === '--port') args.port = argv[++i];
|
|
46
|
+
else if (argv[i] === '--out') args.out = argv[++i];
|
|
47
|
+
}
|
|
48
|
+
if (!args.out) {
|
|
49
|
+
process.stderr.write('console-forwarder: --out <logFile> is required\n');
|
|
50
|
+
process.exit(2);
|
|
51
|
+
}
|
|
52
|
+
return args;
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
const { port, out } = parseArgs(process.argv);
|
|
56
|
+
const statePath = `${out}.forwarder-state.json`;
|
|
57
|
+
|
|
58
|
+
// Bridge-priority coordination: the inspector proxy reliably serves one
|
|
59
|
+
// debugger slot. cdp-bridge holds this lock while a command runs; we yield the
|
|
60
|
+
// slot immediately and re-attach after a settle window — the runtime's console
|
|
61
|
+
// buffer replay backfills everything missed, so no lines are lost.
|
|
62
|
+
const LOCK_FILE = path.join(path.dirname(out), 'cdp-bridge.lock');
|
|
63
|
+
const LOCK_SETTLE_MS = 1500;
|
|
64
|
+
const LOCK_STALE_MS = 30000; // unreadable lock body: crashed bridge must not block logs forever
|
|
65
|
+
// dev-middleware serves one debugger slot per device; when a debugger we do not
|
|
66
|
+
// coordinate with (React Native DevTools) takes it, re-attaching would evict
|
|
67
|
+
// the human back and start a mutual-eviction storm. Stand down for a long
|
|
68
|
+
// window instead — bridge commands still work (they carry their own lock).
|
|
69
|
+
const FOREIGN_DEBUGGER_BACKOFF_MS = 5 * 60 * 1000;
|
|
70
|
+
|
|
71
|
+
function bridgeLockActive() {
|
|
72
|
+
// The lock body is the bridge pid, so liveness is the real signal: a bridge
|
|
73
|
+
// command may legitimately outlive any fixed mtime window (wallet setup runs
|
|
74
|
+
// with CDP_TIMEOUT=120000). mtime staleness only guards an unreadable body.
|
|
75
|
+
let body;
|
|
76
|
+
try {
|
|
77
|
+
body = fs.readFileSync(LOCK_FILE, 'utf8');
|
|
78
|
+
} catch {
|
|
79
|
+
return false;
|
|
80
|
+
}
|
|
81
|
+
const pid = Number.parseInt(body.trim(), 10);
|
|
82
|
+
if (Number.isInteger(pid) && pid > 0) {
|
|
83
|
+
try {
|
|
84
|
+
process.kill(pid, 0);
|
|
85
|
+
return true;
|
|
86
|
+
} catch (error) {
|
|
87
|
+
// EPERM: the pid is alive but owned by another user — still a live bridge.
|
|
88
|
+
return error.code === 'EPERM';
|
|
89
|
+
}
|
|
90
|
+
}
|
|
91
|
+
try {
|
|
92
|
+
return Date.now() - fs.statSync(LOCK_FILE).mtimeMs < LOCK_STALE_MS;
|
|
93
|
+
} catch {
|
|
94
|
+
return false;
|
|
95
|
+
}
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
function yieldSessions() {
|
|
99
|
+
for (const ws of sessions.values()) {
|
|
100
|
+
try {
|
|
101
|
+
ws.close();
|
|
102
|
+
} catch {}
|
|
103
|
+
}
|
|
104
|
+
sessions.clear();
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
let resumeTimer = null;
|
|
108
|
+
try {
|
|
109
|
+
fs.watch(path.dirname(out), (_event, filename) => {
|
|
110
|
+
if (filename !== path.basename(LOCK_FILE)) return;
|
|
111
|
+
if (bridgeLockActive()) {
|
|
112
|
+
if (resumeTimer) {
|
|
113
|
+
clearTimeout(resumeTimer);
|
|
114
|
+
resumeTimer = null;
|
|
115
|
+
}
|
|
116
|
+
yieldSessions();
|
|
117
|
+
} else if (!resumeTimer) {
|
|
118
|
+
resumeTimer = setTimeout(() => {
|
|
119
|
+
resumeTimer = null;
|
|
120
|
+
discover();
|
|
121
|
+
}, LOCK_SETTLE_MS);
|
|
122
|
+
}
|
|
123
|
+
});
|
|
124
|
+
} catch {
|
|
125
|
+
// fs.watch unavailable: the stale-mtime check in discover() still guards us.
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
/**
|
|
129
|
+
* deviceId -> { ts, seen } dedupe cursor: highest consoleAPICalled timestamp
|
|
130
|
+
* already written, plus the formatted texts already written AT that timestamp.
|
|
131
|
+
* Runtime stamps are fractional-ms doubles, but two logs in one tick share a
|
|
132
|
+
* stamp — a timestamp-only cursor dropped the second on replay. A device clock
|
|
133
|
+
* stepping backwards can still drop lines (ts below the cursor): replay is a
|
|
134
|
+
* recovery path, not a ledger.
|
|
135
|
+
*/
|
|
136
|
+
const lastByDevice = new Map();
|
|
137
|
+
try {
|
|
138
|
+
for (const [device, value] of Object.entries(JSON.parse(fs.readFileSync(statePath, 'utf8')))) {
|
|
139
|
+
// Numeric values are state written by a timestamp-only forwarder build.
|
|
140
|
+
lastByDevice.set(
|
|
141
|
+
device,
|
|
142
|
+
typeof value === 'number'
|
|
143
|
+
? { ts: value, seen: new Set() }
|
|
144
|
+
: { ts: Number(value.ts) || 0, seen: new Set(Array.isArray(value.seen) ? value.seen : []) },
|
|
145
|
+
);
|
|
146
|
+
}
|
|
147
|
+
} catch {}
|
|
148
|
+
|
|
149
|
+
function serializeState() {
|
|
150
|
+
const state = {};
|
|
151
|
+
for (const [device, cursor] of lastByDevice) {
|
|
152
|
+
state[device] = { ts: cursor.ts, seen: [...cursor.seen] };
|
|
153
|
+
}
|
|
154
|
+
return JSON.stringify(state);
|
|
155
|
+
}
|
|
156
|
+
|
|
157
|
+
/** deviceId -> live WebSocket session. */
|
|
158
|
+
const sessions = new Map();
|
|
159
|
+
|
|
160
|
+
/** deviceId -> epoch ms until which a foreign debugger owns the slot. */
|
|
161
|
+
const foreignDebuggerUntil = new Map();
|
|
162
|
+
|
|
163
|
+
/** Buffered lines, flushed together so replay bursts are one write. */
|
|
164
|
+
let pending = [];
|
|
165
|
+
let flushTimer = null;
|
|
166
|
+
|
|
167
|
+
function flush() {
|
|
168
|
+
flushTimer = null;
|
|
169
|
+
if (pending.length === 0) return;
|
|
170
|
+
const lines = pending.join('\n');
|
|
171
|
+
pending = [];
|
|
172
|
+
fs.appendFile(out, `${lines}\n`, () => {});
|
|
173
|
+
fs.writeFile(statePath, serializeState(), () => {});
|
|
174
|
+
}
|
|
175
|
+
|
|
176
|
+
// Signal-path flush: process.exit() cancels queued async I/O, so the SIGTERM/
|
|
177
|
+
// SIGINT handlers must write synchronously or pending lines and the last-seen
|
|
178
|
+
// state are lost on every restart (a stale state file re-duplicates replay).
|
|
179
|
+
function flushSync() {
|
|
180
|
+
if (flushTimer) {
|
|
181
|
+
clearTimeout(flushTimer);
|
|
182
|
+
flushTimer = null;
|
|
183
|
+
}
|
|
184
|
+
const lines = pending.length > 0 ? `${pending.join('\n')}\n` : '';
|
|
185
|
+
pending = [];
|
|
186
|
+
try {
|
|
187
|
+
if (lines) fs.appendFileSync(out, lines);
|
|
188
|
+
} catch {}
|
|
189
|
+
try {
|
|
190
|
+
fs.writeFileSync(statePath, serializeState());
|
|
191
|
+
} catch {}
|
|
192
|
+
}
|
|
193
|
+
|
|
194
|
+
function queueLine(line) {
|
|
195
|
+
pending.push(line);
|
|
196
|
+
if (!flushTimer) flushTimer = setTimeout(flush, FLUSH_MS);
|
|
197
|
+
}
|
|
198
|
+
|
|
199
|
+
function levelLabel(type) {
|
|
200
|
+
const t = String(type || 'log').toUpperCase();
|
|
201
|
+
return t === 'WARNING' ? 'WARN' : t;
|
|
202
|
+
}
|
|
203
|
+
|
|
204
|
+
function formatArgs(args) {
|
|
205
|
+
return formatConsoleArgs(args, MAX_LINE_CHARS);
|
|
206
|
+
}
|
|
207
|
+
|
|
208
|
+
function deviceNameFromTitle(title) {
|
|
209
|
+
const m = /\(([^)]+)\)\s*$/.exec(title || '');
|
|
210
|
+
return m ? m[1] : (title || 'device');
|
|
211
|
+
}
|
|
212
|
+
|
|
213
|
+
function deviceIdFromUrl(wsUrl) {
|
|
214
|
+
const m = /[?&]device=([^&]+)/.exec(wsUrl || '');
|
|
215
|
+
return m ? m[1] : wsUrl;
|
|
216
|
+
}
|
|
217
|
+
|
|
218
|
+
function connect(target) {
|
|
219
|
+
const deviceId = deviceIdFromUrl(target.webSocketDebuggerUrl);
|
|
220
|
+
const name = deviceNameFromTitle(target.title);
|
|
221
|
+
let ws;
|
|
222
|
+
try {
|
|
223
|
+
ws = new WebSocket(target.webSocketDebuggerUrl);
|
|
224
|
+
} catch {
|
|
225
|
+
return;
|
|
226
|
+
}
|
|
227
|
+
sessions.set(deviceId, ws);
|
|
228
|
+
// The built-in WebSocket has no handshake timeout; a hung connect would hold
|
|
229
|
+
// the session slot forever and block every future re-attach for this device.
|
|
230
|
+
const handshakeTimer = setTimeout(() => {
|
|
231
|
+
try {
|
|
232
|
+
ws.close();
|
|
233
|
+
} catch {}
|
|
234
|
+
}, HANDSHAKE_TIMEOUT_MS);
|
|
235
|
+
ws.addEventListener('open', () => {
|
|
236
|
+
clearTimeout(handshakeTimer);
|
|
237
|
+
ws.send(JSON.stringify({ id: 1, method: 'Runtime.enable' }));
|
|
238
|
+
process.stderr.write(`console-forwarder: attached ${name}\n`);
|
|
239
|
+
});
|
|
240
|
+
ws.addEventListener('message', (event) => {
|
|
241
|
+
let msg;
|
|
242
|
+
try {
|
|
243
|
+
msg = JSON.parse(String(event.data));
|
|
244
|
+
} catch {
|
|
245
|
+
return;
|
|
246
|
+
}
|
|
247
|
+
if (msg.method !== 'Runtime.consoleAPICalled') return;
|
|
248
|
+
const text = formatArgs(msg.params.args);
|
|
249
|
+
// dev-middleware emits this NOTE on every debugger attach (i.e. ours). Drop it.
|
|
250
|
+
if (text.includes('unsupported debugging client')) return;
|
|
251
|
+
const ts = msg.params.timestamp || Date.now();
|
|
252
|
+
const cursor = lastByDevice.get(deviceId);
|
|
253
|
+
if (cursor && ts < cursor.ts) return;
|
|
254
|
+
if (cursor && ts === cursor.ts) {
|
|
255
|
+
if (cursor.seen.has(text)) return;
|
|
256
|
+
cursor.seen.add(text);
|
|
257
|
+
} else {
|
|
258
|
+
lastByDevice.set(deviceId, { ts, seen: new Set([text]) });
|
|
259
|
+
}
|
|
260
|
+
const time = new Date(ts).toISOString().slice(11, 23);
|
|
261
|
+
queueLine(` ${levelLabel(msg.params.type)} ${time} [console:${name}] ${text}`);
|
|
262
|
+
});
|
|
263
|
+
const drop = () => {
|
|
264
|
+
clearTimeout(handshakeTimer);
|
|
265
|
+
if (sessions.get(deviceId) === ws) {
|
|
266
|
+
sessions.delete(deviceId);
|
|
267
|
+
process.stderr.write(`console-forwarder: detached ${name}; will re-attach\n`);
|
|
268
|
+
}
|
|
269
|
+
};
|
|
270
|
+
ws.addEventListener('close', (event) => {
|
|
271
|
+
// dev-middleware closes the previous debugger with NEW_DEBUGGER_OPENED when
|
|
272
|
+
// another one attaches. With no bridge lock present that debugger is a
|
|
273
|
+
// human's DevTools session — back off long instead of evicting them back.
|
|
274
|
+
const why = event && event.reason ? String(event.reason) : '';
|
|
275
|
+
if (why.includes('NEW_DEBUGGER_OPENED') && !bridgeLockActive()) {
|
|
276
|
+
foreignDebuggerUntil.set(deviceId, Date.now() + FOREIGN_DEBUGGER_BACKOFF_MS);
|
|
277
|
+
process.stderr.write(
|
|
278
|
+
`console-forwarder: another debugger took ${name}; standing down for ${FOREIGN_DEBUGGER_BACKOFF_MS / 60000} min\n`,
|
|
279
|
+
);
|
|
280
|
+
}
|
|
281
|
+
drop();
|
|
282
|
+
});
|
|
283
|
+
ws.addEventListener('error', drop);
|
|
284
|
+
}
|
|
285
|
+
|
|
286
|
+
function discover() {
|
|
287
|
+
if (bridgeLockActive()) {
|
|
288
|
+
schedule(DISCOVER_ACTIVE_MS);
|
|
289
|
+
return;
|
|
290
|
+
}
|
|
291
|
+
http
|
|
292
|
+
.get({ host: 'localhost', port, path: '/json/list', timeout: 3000 }, (res) => {
|
|
293
|
+
let body = '';
|
|
294
|
+
res.on('data', (c) => (body += c));
|
|
295
|
+
res.on('end', () => {
|
|
296
|
+
let targets;
|
|
297
|
+
try {
|
|
298
|
+
targets = JSON.parse(body);
|
|
299
|
+
} catch {
|
|
300
|
+
schedule(DISCOVER_ACTIVE_MS);
|
|
301
|
+
return;
|
|
302
|
+
}
|
|
303
|
+
// Same candidate filter + JS-runtime-first ranking the bridge uses:
|
|
304
|
+
// devices expose multiple pages (page 1 = native C++ runtime) and the
|
|
305
|
+
// first ranked page per device is its JS runtime — attaching to the
|
|
306
|
+
// native page would stream nothing and block the right one.
|
|
307
|
+
let unattached = false;
|
|
308
|
+
const picked = new Set();
|
|
309
|
+
for (const t of rankRuntimeCandidates(targets)) {
|
|
310
|
+
const deviceId = deviceIdFromUrl(t.webSocketDebuggerUrl);
|
|
311
|
+
if (picked.has(deviceId)) continue;
|
|
312
|
+
picked.add(deviceId);
|
|
313
|
+
if (sessions.has(deviceId)) continue;
|
|
314
|
+
if ((foreignDebuggerUntil.get(deviceId) || 0) > Date.now()) continue;
|
|
315
|
+
foreignDebuggerUntil.delete(deviceId);
|
|
316
|
+
unattached = true;
|
|
317
|
+
connect(t);
|
|
318
|
+
}
|
|
319
|
+
schedule(unattached ? DISCOVER_ACTIVE_MS : DISCOVER_STEADY_MS);
|
|
320
|
+
});
|
|
321
|
+
})
|
|
322
|
+
.on('error', () => schedule(DISCOVER_STEADY_MS));
|
|
323
|
+
}
|
|
324
|
+
|
|
325
|
+
let discoverTimer = null;
|
|
326
|
+
function schedule(ms) {
|
|
327
|
+
if (discoverTimer) clearTimeout(discoverTimer);
|
|
328
|
+
discoverTimer = setTimeout(discover, ms);
|
|
329
|
+
}
|
|
330
|
+
|
|
331
|
+
process.on('SIGTERM', () => {
|
|
332
|
+
flushSync();
|
|
333
|
+
process.exit(0);
|
|
334
|
+
});
|
|
335
|
+
process.on('SIGINT', () => {
|
|
336
|
+
flushSync();
|
|
337
|
+
process.exit(0);
|
|
338
|
+
});
|
|
339
|
+
|
|
340
|
+
process.stderr.write(`console-forwarder: streaming Metro :${port} → ${out}\n`);
|
|
341
|
+
discover();
|
|
@@ -0,0 +1,106 @@
|
|
|
1
|
+
'use strict';
|
|
2
|
+
|
|
3
|
+
// Typed bridge failure codes — the single source of truth for classifying a
|
|
4
|
+
// cdp-bridge failure. Substring needles on a critical path are brittle; the
|
|
5
|
+
// bridge classifies at the source and stamps a code, so callers (bridge.mjs,
|
|
6
|
+
// adapters.ts) branch on the code and only fall back to needles for output
|
|
7
|
+
// produced by an older bridge that predates the codes.
|
|
8
|
+
//
|
|
9
|
+
// NO_TARGET no debug target answered (app backgrounded / not attached
|
|
10
|
+
// / a device pin matched nothing).
|
|
11
|
+
// CDP_TIMEOUT the CDP connection or a single CDP message timed out.
|
|
12
|
+
// WS_CLOSED the Hermes debug socket closed mid-command (app reload).
|
|
13
|
+
// METRO_UNREACHABLE Metro's inspector HTTP endpoint could not be reached.
|
|
14
|
+
const BRIDGE_ERROR_CODES = {
|
|
15
|
+
NO_TARGET: 'NO_TARGET',
|
|
16
|
+
CDP_TIMEOUT: 'CDP_TIMEOUT',
|
|
17
|
+
WS_CLOSED: 'WS_CLOSED',
|
|
18
|
+
METRO_UNREACHABLE: 'METRO_UNREACHABLE',
|
|
19
|
+
};
|
|
20
|
+
|
|
21
|
+
// Process exit code the bridge returns per failure code so a caller that only
|
|
22
|
+
// sees the child's exit status (no stderr) can still recover the code. Kept
|
|
23
|
+
// clear of exit 1 (unknown/uncoded) and 2 (usage).
|
|
24
|
+
const EXIT_CODE_BY_ERROR_CODE = {
|
|
25
|
+
NO_TARGET: 10,
|
|
26
|
+
CDP_TIMEOUT: 11,
|
|
27
|
+
WS_CLOSED: 12,
|
|
28
|
+
METRO_UNREACHABLE: 13,
|
|
29
|
+
};
|
|
30
|
+
|
|
31
|
+
const ERROR_CODE_BY_EXIT_CODE = Object.fromEntries(
|
|
32
|
+
Object.entries(EXIT_CODE_BY_ERROR_CODE).map(([code, exit]) => [exit, code]),
|
|
33
|
+
);
|
|
34
|
+
|
|
35
|
+
// Every coded failure teaches the escape for the caller's ACTUAL situation — a
|
|
36
|
+
// down/transitioning target is recoverable, so the Next: line names the action
|
|
37
|
+
// that brings it back rather than leaving the operator to guess.
|
|
38
|
+
const TEACHING_BY_ERROR_CODE = {
|
|
39
|
+
NO_TARGET:
|
|
40
|
+
'Next: bring the app to the foreground with `mm-harness launch <platform>` so a debug target is available.',
|
|
41
|
+
CDP_TIMEOUT:
|
|
42
|
+
'Next: the app is reloading or busy — retry, or `mm-harness launch <platform>` to foreground it.',
|
|
43
|
+
WS_CLOSED:
|
|
44
|
+
'Next: the Hermes debug page closed (app reload/backgrounded) — retry once the app settles.',
|
|
45
|
+
METRO_UNREACHABLE:
|
|
46
|
+
'Next: start Metro with `mm-harness start-metro` (if it is already up, check the slot WATCHER_PORT).',
|
|
47
|
+
};
|
|
48
|
+
|
|
49
|
+
// Needle → code fallback for uncoded output (older bridge). Case-insensitive,
|
|
50
|
+
// first match wins, so order the more specific Metro/no-target needles ahead of
|
|
51
|
+
// the generic 'timed out'.
|
|
52
|
+
const NEEDLE_CODES = [
|
|
53
|
+
['cannot reach metro', BRIDGE_ERROR_CODES.METRO_UNREACHABLE],
|
|
54
|
+
['is metro running', BRIDGE_ERROR_CODES.METRO_UNREACHABLE],
|
|
55
|
+
['timeout fetching', BRIDGE_ERROR_CODES.METRO_UNREACHABLE],
|
|
56
|
+
['no responding bridge target', BRIDGE_ERROR_CODES.NO_TARGET],
|
|
57
|
+
['no debug targets found', BRIDGE_ERROR_CODES.NO_TARGET],
|
|
58
|
+
['no suitable debug target', BRIDGE_ERROR_CODES.NO_TARGET],
|
|
59
|
+
['did not match any metro target', BRIDGE_ERROR_CODES.NO_TARGET],
|
|
60
|
+
['pinned android device', BRIDGE_ERROR_CODES.NO_TARGET],
|
|
61
|
+
['no react native bridge target', BRIDGE_ERROR_CODES.NO_TARGET],
|
|
62
|
+
['websocket closed', BRIDGE_ERROR_CODES.WS_CLOSED],
|
|
63
|
+
['websocket error', BRIDGE_ERROR_CODES.WS_CLOSED],
|
|
64
|
+
['cdp connection timeout', BRIDGE_ERROR_CODES.CDP_TIMEOUT],
|
|
65
|
+
['cdp message timeout', BRIDGE_ERROR_CODES.CDP_TIMEOUT],
|
|
66
|
+
['evaluation timed out', BRIDGE_ERROR_CODES.CDP_TIMEOUT],
|
|
67
|
+
['timed out', BRIDGE_ERROR_CODES.CDP_TIMEOUT],
|
|
68
|
+
];
|
|
69
|
+
|
|
70
|
+
function classifyBridgeErrorMessage(message) {
|
|
71
|
+
const text = String(message == null ? '' : message).toLowerCase();
|
|
72
|
+
for (const [needle, code] of NEEDLE_CODES) {
|
|
73
|
+
if (text.includes(needle)) return code;
|
|
74
|
+
}
|
|
75
|
+
return null;
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
// Attach a code to an error at its throw site (source classification).
|
|
79
|
+
function coded(error, code) {
|
|
80
|
+
if (error && typeof error === 'object') error.code = code;
|
|
81
|
+
return error;
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
// The bridge prints this marker on stderr so a caller recovers the code without
|
|
85
|
+
// relying on the exit status alone.
|
|
86
|
+
const MARKER = /^ERROR\[([A-Z_]+)\]:/mu;
|
|
87
|
+
|
|
88
|
+
function formatErrorMarker(code, message) {
|
|
89
|
+
return `ERROR[${code}]: ${message}`;
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
function parseErrorMarker(text) {
|
|
93
|
+
const match = MARKER.exec(String(text == null ? '' : text));
|
|
94
|
+
return match ? match[1] : null;
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
module.exports = {
|
|
98
|
+
BRIDGE_ERROR_CODES,
|
|
99
|
+
EXIT_CODE_BY_ERROR_CODE,
|
|
100
|
+
ERROR_CODE_BY_EXIT_CODE,
|
|
101
|
+
TEACHING_BY_ERROR_CODE,
|
|
102
|
+
classifyBridgeErrorMessage,
|
|
103
|
+
coded,
|
|
104
|
+
formatErrorMarker,
|
|
105
|
+
parseErrorMarker,
|
|
106
|
+
};
|
|
@@ -0,0 +1,53 @@
|
|
|
1
|
+
'use strict';
|
|
2
|
+
|
|
3
|
+
// Render CDP Runtime.consoleAPICalled args (RemoteObjects) into a log line.
|
|
4
|
+
// Primitives carry `value`; objects/arrays do not — a bare console.log(obj)
|
|
5
|
+
// arrived as the literal "Object". Hermes ships an inline `preview` (the same
|
|
6
|
+
// one React Native DevTools renders in its console), so objects expand from it
|
|
7
|
+
// synchronously — no extra CDP round-trip, so the stream never blocks. Depth is
|
|
8
|
+
// bounded because previews nest one level via `valuePreview`; beyond that the
|
|
9
|
+
// child preview's own description is used.
|
|
10
|
+
|
|
11
|
+
const MAX_PREVIEW_DEPTH = 2;
|
|
12
|
+
|
|
13
|
+
function formatPropertyValue(prop, depth) {
|
|
14
|
+
if (prop.valuePreview && depth < MAX_PREVIEW_DEPTH) {
|
|
15
|
+
return formatPreview(prop.valuePreview, depth + 1);
|
|
16
|
+
}
|
|
17
|
+
if (prop.type === 'string' && prop.value !== undefined) return JSON.stringify(prop.value);
|
|
18
|
+
if (prop.value !== undefined) return String(prop.value);
|
|
19
|
+
return prop.subtype || prop.type || '';
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
function formatPreview(preview, depth) {
|
|
23
|
+
const properties = Array.isArray(preview.properties) ? preview.properties : [];
|
|
24
|
+
const overflow = preview.overflow ? ', …' : '';
|
|
25
|
+
if (preview.subtype === 'array') {
|
|
26
|
+
return `[${properties.map((p) => formatPropertyValue(p, depth)).join(', ')}${overflow}]`;
|
|
27
|
+
}
|
|
28
|
+
const body = properties.map((p) => `${p.name}: ${formatPropertyValue(p, depth)}`).join(', ');
|
|
29
|
+
// Name a non-plain constructor (Error, Map, custom class) so the class is not lost.
|
|
30
|
+
const ctor = preview.description && preview.description !== 'Object' ? `${preview.description} ` : '';
|
|
31
|
+
return properties.length > 0 || overflow ? `${ctor}{ ${body}${overflow} }` : `${ctor}{}`;
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
function formatRemoteObject(a) {
|
|
35
|
+
if (!a || typeof a !== 'object') return String(a == null ? '' : a);
|
|
36
|
+
// Primitives (number/boolean/string) carry a directly-usable value.
|
|
37
|
+
if (a.type !== 'object' && a.type !== 'function' && a.value !== undefined) return String(a.value);
|
|
38
|
+
if (a.type === 'undefined') return 'undefined';
|
|
39
|
+
if (a.subtype === 'null') return 'null';
|
|
40
|
+
if (a.preview) return formatPreview(a.preview, 1);
|
|
41
|
+
// Functions, errors, and objects with no preview: the description is the best
|
|
42
|
+
// available text (className / Error stack / etc.); fall back to bare value/type.
|
|
43
|
+
if (a.description !== undefined) return a.description;
|
|
44
|
+
if (a.value !== undefined) return String(a.value);
|
|
45
|
+
return a.type || '';
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
function formatArgs(args, maxLineChars) {
|
|
49
|
+
const text = (args || []).map(formatRemoteObject).join(' ');
|
|
50
|
+
return maxLineChars && text.length > maxLineChars ? `${text.slice(0, maxLineChars)}…` : text;
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
module.exports = { formatArgs, formatRemoteObject, formatPreview };
|