@deeeed/metamask-harness 0.12.0 → 0.14.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 (90) hide show
  1. package/CHANGELOG.md +26 -5
  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 +4 -4
  18. package/adapters/extension/wallet-fixture-state.cjs +0 -0
  19. package/adapters/mobile/bridge-runtime/cdp-bridge.cjs +16 -0
  20. package/adapters/mobile/bridge-runtime/console-forwarder.cjs +2 -4
  21. package/adapters/mobile/bridge-runtime/lib/bridge-errors.cjs +106 -0
  22. package/adapters/mobile/bridge-runtime/lib/console-format.cjs +53 -0
  23. package/adapters/mobile/bridge-runtime/lib/match-bridge-target.cjs +82 -0
  24. package/adapters/mobile/bridge-runtime/lib/target-discovery.cjs +17 -13
  25. package/adapters/mobile/bridge-runtime/lib/ws-client.cjs +10 -5
  26. package/adapters/mobile/bridge-runtime/setup-wallet.sh +0 -0
  27. package/adapters/mobile/cleanup.sh +0 -0
  28. package/adapters/mobile/inject.sh +0 -0
  29. package/adapters/mobile/lib/metro-listener.sh +0 -0
  30. package/adapters/mobile/lib/tmux-viewer.sh +0 -0
  31. package/adapters/mobile/open-device.sh +0 -0
  32. package/adapters/mobile/prewarm-bundle.sh +0 -0
  33. package/adapters/mobile/start-metro.sh +22 -1
  34. package/adapters/mobile/stop-metro.sh +0 -0
  35. package/adapters/mobile/verify.sh +50 -13
  36. package/adapters/mobile/wait-for-bridge.sh +41 -23
  37. package/adapters/mobile/yarn-setup.sh +0 -0
  38. package/adapters/shared/activate-repo-node.sh +0 -0
  39. package/adapters/shared/activate-repo-ruby.sh +0 -0
  40. package/adapters/shared/cli-ux.sh +0 -0
  41. package/adapters/shared/ensure-runner-deps.sh +0 -0
  42. package/adapters/shared/harness-path.sh +0 -0
  43. package/adapters/shared/hash-helpers.sh +0 -0
  44. package/adapters/shared/json-field.sh +0 -0
  45. package/adapters/shared/log-tui.mjs +7 -3
  46. package/adapters/shared/open-log-window.sh +0 -0
  47. package/adapters/shared/reap-checkout-metros.sh +0 -0
  48. package/adapters/shared/resolve-farmslot-ports.mjs +0 -0
  49. package/adapters/shared/resolve-farmslot-ports.sh +0 -0
  50. package/adapters/shared/resolve-slot-ports.mjs +0 -0
  51. package/adapters/shared/resolve-slot-ports.sh +0 -0
  52. package/adapters/shared/sync-wallet-fixture.sh +0 -0
  53. package/adapters/shared/tmux-session.sh +0 -0
  54. package/dist/adapters/mobile/prepare.js +23 -2
  55. package/dist/adapters/mobile/runtime-decision.js +2 -1
  56. package/dist/adapters.js +33 -10
  57. package/dist/cli-commands.js +5 -4
  58. package/dist/commands/call.js +138 -3
  59. package/dist/commands/device-target.js +10 -3
  60. package/dist/commands/fixtures.js +2 -1
  61. package/dist/commands/manifest.js +2 -0
  62. package/dist/commands/run-engine.js +71 -4
  63. package/dist/commands/run.js +2 -0
  64. package/dist/commands/self-test.js +1 -1
  65. package/dist/commands/status.js +19 -4
  66. package/dist/mm-harness-cli.js +19 -5
  67. package/dist/recipe-files.js +14 -0
  68. package/docs/architecture.md +2 -2
  69. package/docs/live-adapter-contract.md +1 -1
  70. package/docs/recipe-libraries.md +21 -18
  71. package/library/actions/mobile/platform/bridge.mjs +35 -4
  72. package/library/recipes/{app-lifecycle-android-smoke.mobile.recipe.json → app/lifecycle.android-smoke.mobile.recipe.json} +1 -1
  73. package/library/recipes/{perps-performance.mobile.recipe.json → perps/performance.mobile.recipe.json} +1 -1
  74. package/library/recipes/perps/smoke.core.recipe.json +39 -0
  75. package/library/recipes/perps/smoke.extension.recipe.json +51 -0
  76. package/library/recipes/perps/smoke.mobile.recipe.json +51 -0
  77. package/library/recipes/{action-validation.extension.recipe.json → runner/action-validation.extension.recipe.json} +7 -7
  78. package/library/recipes/{action-validation.mobile.recipe.json → runner/action-validation.mobile.recipe.json} +7 -7
  79. package/package.json +2 -2
  80. package/scripts/completions.sh +0 -0
  81. package/scripts/install-completions.sh +0 -0
  82. /package/library/recipes/{perps-lifecycle.recipe.json → perps/lifecycle.recipe.json} +0 -0
  83. /package/library/recipes/{order-lifecycle.core.recipe.json → perps/order-lifecycle.core.recipe.json} +0 -0
  84. /package/library/recipes/{perps-performance-background-resume.mobile.recipe.json → perps/performance.background-resume.mobile.recipe.json} +0 -0
  85. /package/library/recipes/{perps-performance-cold-start.mobile.recipe.json → perps/performance.cold-start.mobile.recipe.json} +0 -0
  86. /package/library/recipes/{perps-performance-warm-start.mobile.recipe.json → perps/performance.warm-start.mobile.recipe.json} +0 -0
  87. /package/library/recipes/{read-markets.core.recipe.json → perps/read-markets.core.recipe.json} +0 -0
  88. /package/library/recipes/{trading-lifecycle.core.recipe.json → perps/trading-lifecycle.core.recipe.json} +0 -0
  89. /package/library/recipes/{smoke.extension.recipe.json → runner/smoke.extension.recipe.json} +0 -0
  90. /package/library/recipes/{smoke.mobile.recipe.json → runner/smoke.mobile.recipe.json} +0 -0
package/CHANGELOG.md CHANGED
@@ -1,9 +1,30 @@
1
1
  # Changelog
2
2
 
3
- ## Unreleased
3
+ ## 0.14.0 - 2026-07-08
4
+
5
+ ### Added
6
+ - `mm-harness call` accepts action fields as direct `key=value` shorthand in addition to repeated `--arg k=v`, so commands like `mm-harness call ui.navigate page=perps` now match the examples agents naturally write.
7
+ - Packaged recipes are grouped by domain under `library/recipes/<domain>/` and still resolve by dotted names (`perps.smoke`, `runner.smoke`) with legacy aliases for the former flat names.
8
+ - Packaged `perps.smoke` recipes for mobile, extension, and core provide an explicit non-mutating Perps domain smoke. The existing bare `smoke` alias remains a runner/transport smoke for compatibility.
9
+
10
+ ### Fixed
11
+ - `status` now hydrates slot runtime context before device enumeration, so a Farmslot checkout pinned to `IOS_SIMULATOR=mm-1` reports only the selected slot simulator instead of every booted simulator/device on the machine. Live status also updates `Next:` to `mm-harness logs` when the selected bridge is reachable.
12
+ - The tmux Metro window opened by `launch ios|android` now streams the Metro output it writes to `metro.log` instead of opening a blank server window.
13
+ - `log-tui watch` now matches success/failure patterns only against log content emitted after the watcher starts, so stale markers already present in long-lived Metro/webpack logs cannot false-pass readiness checks.
14
+ - `start-metro` now reaps any existing mobile console-forwarder attached to the same Metro port before spawning this slot's forwarder, even when the old process was launched from another checkout or package install path.
15
+
16
+ ## 0.13.0 - 2026-07-08
17
+
18
+ ### Added
19
+ - 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.
20
+ - 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.
21
+ - `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).
4
22
 
5
23
  ### Fixed
6
24
  - 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.
25
+ - 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.
26
+ - 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.
27
+ - `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.
7
28
 
8
29
  ## 0.12.0 - 2026-07-08
9
30
 
@@ -21,7 +42,7 @@
21
42
  ### Added
22
43
  - 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
44
  - `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.
45
+ - 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
46
 
26
47
  ### Changed
27
48
  - `@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).
@@ -38,9 +59,9 @@
38
59
  ## 0.10.0 - 2026-07-07
39
60
 
40
61
  ### Added
41
- - **Canonical `perps-performance` measured flow** (`library/recipes/perps-performance.mobile.recipe.json`): unlock → open the Perps market list → read live state → open a market detail — one node per user-visible step with stable node names, so the per-node `duration`s in `trace.json` are the timings you monitor. Run pinned: `mm-harness run perps-performance --device <serial> --heal off`. Device-proven end-to-end on a physical Pixel.
62
+ - **Canonical `perps.performance` measured flow** (`library/recipes/perps/performance.mobile.recipe.json`): unlock → open the Perps market list → read live state → open a market detail — one node per user-visible step with stable node names, so the per-node `duration`s in `trace.json` are the timings you monitor. Run pinned: `mm-harness run perps.performance --device <serial> --heal off`. Device-proven end-to-end on a physical Pixel.
42
63
  - **`run <name>` resolves personal/team recipe libraries.** Previously only the packaged library was probed by name; custom recipes ran by path. Sources resolve in library-precedence order (personal > team shadow the packaged canonical — a same-named personal recipe wins), path-shaped args never probe libraries, and a miss teaches which sources were searched. Zero-flag default: `$FARMSLOT_HOME/recipe-library`.
43
- - **Custom-library walkthrough** ("Your own measured flow" in `docs/recipe-libraries.md`): a peer engineer scaffolds a personal library, copies the canonical flow, retargets the nodes to their journey, and runs it by name. Every step is executed by the `perps-performance-recipe` contract test, so the doc cannot drift from reality.
64
+ - **Custom-library walkthrough** ("Your own measured flow" in `docs/recipe-libraries.md`): a peer engineer scaffolds a personal library, copies the canonical flow, retargets the nodes to their journey, and runs it by name. Every step is executed by the `perps.performance-recipe` contract test, so the doc cannot drift from reality.
44
65
  - **`call` declared on mobile and extension manifests** (was core-only) with self-discovery metadata — personal `flows/` segments are now usable from mobile/extension recipes. Honest limitation documented: flows carry no adapter dimension in the protocol yet, so a cross-adapter `call` fails at live-run rather than plan time.
45
66
 
46
67
  ### Fixed
@@ -63,7 +84,7 @@
63
84
  ## 0.8.0 - 2026-07-07
64
85
 
65
86
  ### Added
66
- - **`run <name>` — the recipe positional resolves packaged-library recipe names.** An existing file path (absolute or cwd-relative) wins; otherwise the arg is a library recipe name, adapter-scoped first (`run smoke` on mobile → `smoke.mobile.recipe.json`), then adapterless (`run perps-lifecycle`), then the exact library filename. A miss teaches `RECIPE_NOT_FOUND` naming the library recipes available for the adapter — no more `$LIB`-style path prefixes in docs or demo scripts. A directory shadowing a library name never wins, and path-shaped args (containing a separator) never fall through to the library.
87
+ - **`run <name>` — the recipe positional resolves packaged-library recipe names.** An existing file path (absolute or cwd-relative) wins; otherwise the arg is a library recipe name, adapter-scoped first (`run smoke` on mobile → `library/recipes/runner/smoke.mobile.recipe.json`), then adapterless (`run perps.lifecycle` → `library/recipes/perps/lifecycle.recipe.json`), then the exact library filename. A miss teaches `RECIPE_NOT_FOUND` naming the library recipes available for the adapter — no more `$LIB`-style path prefixes in docs or demo scripts. A directory shadowing a library name never wins, and path-shaped args (containing a separator) never fall through to the library.
67
88
  - **`--device <udid|serial|name>` — first-class mobile device targeting on `run`, `call`, and `doctor`.** Resolves against connected devices (adb + booted simulators; exact id first, then exact name with an ambiguity teaching error) and sets the same env `launch --device` does. Mobile `run`/`call` **without** `--device` while more than one targetable device is connected (across android + ios; android state `device`, iOS `Booted`) fail fast listing the devices and the `--device` hint, so a recipe never lands on the wrong device silently. `run --plan`/`--list` are exempt; extension/core teach. Errors honor `--json` with structured envelopes.
68
89
  - **`status` gains `devices[]`** (additive): `{platform, id, name, state, selected}` for connected android devices and booted iOS simulators on mobile checkouts — the `yarn a:status` successor; `selected` reflects the current env pinning.
69
90
 
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
@@ -88,7 +88,7 @@ RUNNER_BIN="$HARNESS_DIR/runner/bin/mm-harness"
88
88
  # --out (optional): a task-local recipes dir. Resolve it safely within the target
89
89
  # (resolve_harness_out rejects absolute/.. escapes) and prefer its smoke recipe so
90
90
  # `live --out <dir>` does not silently fall back to the installed default.
91
- SMOKE_RECIPE="$HARNESS_DIR/runner/recipes/smoke.extension.recipe.json"
91
+ SMOKE_RECIPE="$HARNESS_DIR/runner/recipes/runner/smoke.extension.recipe.json"
92
92
  if [ -n "$OUT" ]; then
93
93
  # Fail fast on a task-local --out that does not contain the requested recipe.
94
94
  # Silently falling back to the installed default would validate a different
@@ -98,11 +98,11 @@ if [ -n "$OUT" ]; then
98
98
  echo "extension verify: --out '$OUT' did not resolve to a safe path under the target." >&2
99
99
  exit 2
100
100
  fi
101
- if [ ! -f "$OUT_ABS/smoke.extension.recipe.json" ]; then
102
- echo "extension verify: --out '$OUT' (resolved: $OUT_ABS) has no smoke.extension.recipe.json. Refusing to fall back to the installed default recipe; place the recipe under --out or omit --out." >&2
101
+ if [ ! -f "$OUT_ABS/runner/smoke.extension.recipe.json" ]; then
102
+ echo "extension verify: --out '$OUT' (resolved: $OUT_ABS) has no runner/smoke.extension.recipe.json. Refusing to fall back to the installed default recipe; place the recipe under --out or omit --out." >&2
103
103
  exit 2
104
104
  fi
105
- SMOKE_RECIPE="$OUT_ABS/smoke.extension.recipe.json"
105
+ SMOKE_RECIPE="$OUT_ABS/runner/smoke.extension.recipe.json"
106
106
  fi
107
107
  ARTIFACTS="${ARTIFACTS:-$HARNESS_DIR/verify/$(date -u +%Y%m%dT%H%M%SZ)}"
108
108
  mkdir -p "$ARTIFACTS/logs"
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
 
@@ -746,6 +752,16 @@ Environment:
746
752
  }
747
753
 
748
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
+ }
749
765
  console.error(`ERROR: ${err.message}`);
750
766
  process.exit(1);
751
767
  });
@@ -28,6 +28,7 @@ const fs = require('node:fs');
28
28
  const http = require('node:http');
29
29
  const path = require('node:path');
30
30
  const { rankRuntimeCandidates } = require('./lib/target-discovery.cjs');
31
+ const { formatArgs: formatConsoleArgs } = require('./lib/console-format.cjs');
31
32
 
32
33
  // Built-in WebSocket (Node 22+), same zero-dependency transport choice as
33
34
  // lib/ws-client.cjs — the published package must not depend on `ws`.
@@ -201,10 +202,7 @@ function levelLabel(type) {
201
202
  }
202
203
 
203
204
  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;
205
+ return formatConsoleArgs(args, MAX_LINE_CHARS);
208
206
  }
209
207
 
210
208
  function deviceNameFromTitle(title) {
@@ -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 };
@@ -0,0 +1,82 @@
1
+ 'use strict';
2
+
3
+ // Boundary-safe matcher for CDP bridge targets, shared by wait-for-bridge and the
4
+ // launch ready-path confirm. A launch (or its confirm) may only accept a target
5
+ // belonging to the REQUESTED platform — and the requested device when one is pinned.
6
+ //
7
+ // The requested platform is authoritative and is passed explicitly
8
+ // (WAIT_FOR_BRIDGE_PLATFORM), so it wins over env inference. On a dual-platform slot
9
+ // the other platform's device identity is ambiently injected (a slot-context
10
+ // IOS_SIMULATOR while launching android, or vice versa); without an explicit platform
11
+ // the matcher would fall back to that ambient identity and confirm against the wrong
12
+ // platform's target. Binding the platform up front closes that corner.
13
+
14
+ function matchesBridgeTarget(target, env) {
15
+ const e = env || process.env;
16
+ if (!target || typeof target !== 'object') return false;
17
+
18
+ const requirePlatform = e.WAIT_FOR_BRIDGE_PLATFORM || '';
19
+ // Requested platform is authoritative: never accept the other platform's target,
20
+ // whatever device env is ambiently present.
21
+ if (requirePlatform && target.platform !== requirePlatform) return false;
22
+
23
+ const androidName = e.ANDROID_TARGET_DEVICE_NAME || e.ANDROID_DEVICE || '';
24
+ const adbSerial = e.ADB_SERIAL || e.ANDROID_SERIAL || '';
25
+ const iosSimulator = e.IOS_SIMULATOR || '';
26
+
27
+ // Android device pin applies only when android is requested (or nothing constrains
28
+ // the platform); it must never redirect a platform-bound iOS confirm — a slot-context
29
+ // ADB_SERIAL while launching iOS must not reject every iOS target. Symmetric with the
30
+ // iOS-simulator branch below.
31
+ if ((adbSerial || androidName) && (!requirePlatform || requirePlatform === 'android')) {
32
+ // Require android + boundary-safe device-name match.
33
+ if (target.platform !== 'android') return false;
34
+ if (!androidName) return true;
35
+ const deviceName = String(target.deviceName || '');
36
+ return deviceName === androidName || deviceName.startsWith(`${androidName} -`);
37
+ }
38
+
39
+ // iOS simulator pin applies only when iOS is requested (or nothing constrains the
40
+ // platform); it must never redirect a platform-bound android confirm.
41
+ if (iosSimulator && (!requirePlatform || requirePlatform === 'ios')) {
42
+ return target.platform !== 'android' && target.deviceName === iosSimulator;
43
+ }
44
+
45
+ // No device pin: the platform gate above is the only constraint.
46
+ return true;
47
+ }
48
+
49
+ // True when at least one answering target matches the request AND carries a route
50
+ // (an in-app agentic bridge is live), not merely a registered debug target.
51
+ function hasMatchingRoute(value, env) {
52
+ const targets = Array.isArray(value) ? value : [value];
53
+ return targets.some((t) => matchesBridgeTarget(t, env) && t && t.route);
54
+ }
55
+
56
+ // Human summary of the targets that answered (platform / deviceName), for teaching on
57
+ // a timeout — e.g. an iOS target answering an android launch.
58
+ function describeTargets(value) {
59
+ const targets = (Array.isArray(value) ? value : [value]).filter((t) => t && typeof t === 'object');
60
+ if (!targets.length) return 'none';
61
+ return targets
62
+ .map((t) => `${t.platform || '?'}${t.deviceName ? `/'${t.deviceName}'` : ''}${t.route ? '' : ' (no route)'}`)
63
+ .join(', ');
64
+ }
65
+
66
+ // Human summary of what the run is pinned to, for the same teaching line.
67
+ function describeRequested(env) {
68
+ const e = env || process.env;
69
+ const platform = e.WAIT_FOR_BRIDGE_PLATFORM || '';
70
+ const androidName = e.ANDROID_TARGET_DEVICE_NAME || e.ANDROID_DEVICE || '';
71
+ const adbSerial = e.ADB_SERIAL || e.ANDROID_SERIAL || '';
72
+ const iosSimulator = e.IOS_SIMULATOR || '';
73
+ // Explicit requested platform wins over device-env inference: an iOS-bound confirm
74
+ // with an ambient ADB_SERIAL is still iOS.
75
+ if (platform === 'android') return `android${androidName ? ` / ${androidName}` : ''}`;
76
+ if (platform === 'ios') return `ios${iosSimulator ? ` / ${iosSimulator}` : ''}`;
77
+ if (adbSerial || androidName) return `android${androidName ? ` / ${androidName}` : ''}`;
78
+ if (iosSimulator) return `ios${iosSimulator ? ` / ${iosSimulator}` : ''}`;
79
+ return 'any platform';
80
+ }
81
+
82
+ module.exports = { matchesBridgeTarget, hasMatchingRoute, describeTargets, describeRequested };
@@ -3,6 +3,7 @@
3
3
  const http = require('node:http');
4
4
  const { loadSimulatorName, loadAndroidDevice, loadAndroidTargetDeviceName } = require('./config.cjs');
5
5
  const { createWSClient } = require('./ws-client.cjs');
6
+ const { BRIDGE_ERROR_CODES, coded } = require('./bridge-errors.cjs');
6
7
 
7
8
  const FETCH_TIMEOUT_MS = Number.parseInt(process.env.CDP_TIMEOUT || '30000', 10);
8
9
  const FETCH_RETRIES = Number.parseInt(process.env.CDP_DISCOVERY_RETRIES || '3', 10);
@@ -117,13 +118,14 @@ async function discoverTarget(port) {
117
118
  try {
118
119
  targets = await fetchJSON(listUrl);
119
120
  } catch (e) {
120
- throw new Error(
121
- `Cannot reach Metro at ${listUrl}. Is Metro running?\n ${e.message}`,
121
+ throw coded(
122
+ new Error(`Cannot reach Metro at ${listUrl}. Is Metro running?\n ${e.message}`),
123
+ BRIDGE_ERROR_CODES.METRO_UNREACHABLE,
122
124
  );
123
125
  }
124
126
 
125
127
  if (!Array.isArray(targets) || targets.length === 0) {
126
- throw new Error(`No debug targets found at ${listUrl}`);
128
+ throw coded(new Error(`No debug targets found at ${listUrl}`), BRIDGE_ERROR_CODES.NO_TARGET);
127
129
  }
128
130
 
129
131
  // Filter to React Native / Hermes targets with a WebSocket URL
@@ -181,25 +183,25 @@ async function discoverTarget(port) {
181
183
  const ambiguousList = androidFiltered
182
184
  .map((t) => ` deviceName=${JSON.stringify(t.deviceName || '')} ws=${t.webSocketDebuggerUrl || ''}`)
183
185
  .join('\n');
184
- throw new Error(
186
+ throw coded(new Error(
185
187
  `Pinned Android device is ambiguous: model '${androidTargetName}' matches ${androidFiltered.length} Metro targets.\n` +
186
188
  ` Requested --device (ADB_SERIAL): ${adbSerial || '(not set)'}\n` +
187
189
  ` Matching Metro targets:\n${ambiguousList}\n` +
188
190
  ` Set ANDROID_DEVICE to the exact Metro deviceName to disambiguate.`,
189
- );
191
+ ), BRIDGE_ERROR_CODES.NO_TARGET);
190
192
  }
191
193
  if (androidFiltered.length === 0) {
192
194
  // Pinned device could not be matched — never silently pick another target.
193
195
  const candidateList = targets
194
196
  .map((t) => ` deviceName=${JSON.stringify(t.deviceName || '')} ws=${t.webSocketDebuggerUrl || ''}`)
195
197
  .join('\n');
196
- throw new Error(
198
+ throw coded(new Error(
197
199
  `Pinned Android device did not match any Metro target.\n` +
198
200
  ` Requested --device (ADB_SERIAL): ${adbSerial || '(not set)'}\n` +
199
201
  ` Resolved model (ANDROID_TARGET_DEVICE_NAME): ${androidTargetName}\n` +
200
202
  ` ANDROID_DEVICE: ${androidDevice || '(not set)'}\n` +
201
203
  ` Metro /json/list candidates:\n${candidateList}`,
202
- );
204
+ ), BRIDGE_ERROR_CODES.NO_TARGET);
203
205
  }
204
206
  candidates = androidFiltered;
205
207
  } else if (androidDevice) {
@@ -211,11 +213,11 @@ async function discoverTarget(port) {
211
213
  const candidateList = targets
212
214
  .map((t) => ` deviceName=${JSON.stringify(t.deviceName || '')} ws=${t.webSocketDebuggerUrl || ''}`)
213
215
  .join('\n');
214
- throw new Error(
216
+ throw coded(new Error(
215
217
  `Pinned Android device (ANDROID_DEVICE='${androidDevice}') did not match any Metro target.\n` +
216
218
  ` ADB_SERIAL: ${adbSerial || '(not set)'}\n` +
217
219
  ` Metro /json/list candidates:\n${candidateList}`,
218
- );
220
+ ), BRIDGE_ERROR_CODES.NO_TARGET);
219
221
  }
220
222
  candidates = androidFiltered;
221
223
  }
@@ -226,8 +228,9 @@ async function discoverTarget(port) {
226
228
  }
227
229
 
228
230
  if (candidates.length === 0) {
229
- throw new Error(
230
- `No suitable debug target found. Targets:\n${JSON.stringify(targets, null, 2)}`,
231
+ throw coded(
232
+ new Error(`No suitable debug target found. Targets:\n${JSON.stringify(targets, null, 2)}`),
233
+ BRIDGE_ERROR_CODES.NO_TARGET,
231
234
  );
232
235
  }
233
236
 
@@ -263,8 +266,9 @@ async function discoverAllTargets(port) {
263
266
  try {
264
267
  targets = await fetchJSON(listUrl);
265
268
  } catch (e) {
266
- throw new Error(
267
- `Cannot reach Metro at ${listUrl}. Is Metro running?\n ${e.message}`,
269
+ throw coded(
270
+ new Error(`Cannot reach Metro at ${listUrl}. Is Metro running?\n ${e.message}`),
271
+ BRIDGE_ERROR_CODES.METRO_UNREACHABLE,
268
272
  );
269
273
  }
270
274
 
@@ -2,6 +2,8 @@
2
2
 
3
3
  /* global globalThis */
4
4
 
5
+ const { BRIDGE_ERROR_CODES, coded } = require('./bridge-errors.cjs');
6
+
5
7
  /**
6
8
  * Minimal CDP client using the built-in ws-like interface over raw WebSocket.
7
9
  * Node 22+ has a built-in WebSocket; for older versions we use the ws package
@@ -32,7 +34,7 @@ function createWSClient(wsUrl, timeout) {
32
34
 
33
35
  const timer = setTimeout(() => {
34
36
  ws.close();
35
- reject(new Error(`CDP connection timeout after ${timeout}ms`));
37
+ reject(coded(new Error(`CDP connection timeout after ${timeout}ms`), BRIDGE_ERROR_CODES.CDP_TIMEOUT));
36
38
  }, timeout);
37
39
 
38
40
  ws.onopen = () => {
@@ -45,8 +47,11 @@ function createWSClient(wsUrl, timeout) {
45
47
  const timer = setTimeout(() => {
46
48
  pending.delete(id);
47
49
  rej(
48
- new Error(
49
- `CDP message timeout after ${msgTimeout}ms for ${method}`,
50
+ coded(
51
+ new Error(
52
+ `CDP message timeout after ${msgTimeout}ms for ${method}`,
53
+ ),
54
+ BRIDGE_ERROR_CODES.CDP_TIMEOUT,
50
55
  ),
51
56
  );
52
57
  }, msgTimeout);
@@ -92,13 +97,13 @@ function createWSClient(wsUrl, timeout) {
92
97
 
93
98
  ws.onerror = (err) => {
94
99
  clearTimeout(timer);
95
- reject(new Error(`WebSocket error: ${err.message || err}`));
100
+ reject(coded(new Error(`WebSocket error: ${err.message || err}`), BRIDGE_ERROR_CODES.WS_CLOSED));
96
101
  };
97
102
 
98
103
  ws.onclose = () => {
99
104
  clearTimeout(timer);
100
105
  for (const [, { reject: rej }] of pending) {
101
- rej(new Error('WebSocket closed'));
106
+ rej(coded(new Error('WebSocket closed'), BRIDGE_ERROR_CODES.WS_CLOSED));
102
107
  }
103
108
  pending.clear();
104
109
  };
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
@@ -86,6 +86,20 @@ start_console_forwarder() {
86
86
  local fwd_pid_file="$LOG_DIR/console-forwarder.pid"
87
87
  [ "${METAMASK_RECIPE_CONSOLE_FORWARD:-1}" != "0" ] || return 0
88
88
  { [ -f "$forwarder" ] && command -v node >/dev/null 2>&1; } || return 0
89
+ # Only one debugger can own a React Native page. Reap any forwarder already
90
+ # attached to this Metro port, even if it was launched from another checkout or
91
+ # package install path and therefore does not own this runtime dir's pid file.
92
+ while IFS= read -r line; do
93
+ local pid cmd
94
+ pid="${line%% *}"
95
+ cmd="${line#* }"
96
+ [ "$pid" != "$line" ] || continue
97
+ case "$cmd" in
98
+ *console-forwarder.cjs*" --port $PORT "*|*console-forwarder.cjs*" --port=$PORT "*)
99
+ kill "$pid" 2>/dev/null || true
100
+ ;;
101
+ esac
102
+ done < <(ps -axo pid=,command= 2>/dev/null || true)
89
103
  if [ -f "$fwd_pid_file" ]; then
90
104
  local old_fwd
91
105
  old_fwd="$(cat "$fwd_pid_file" 2>/dev/null || true)"
@@ -120,10 +134,13 @@ set -a
120
134
  [ ! -f .env.local ] || . ./.env.local
121
135
  set +a
122
136
  [ -n "\${METAMASK_BUILD_TYPE:-}" ] || export METAMASK_BUILD_TYPE=main
137
+ # build.sh remaps MM_SENTRY_DSN from MM_SENTRY_DSN_DEV for dev builds; quick-launch
138
+ # bypasses it, so without this Sentry never initializes in the dev client.
139
+ [ -n "\${MM_SENTRY_DSN:-}" ] || export MM_SENTRY_DSN="\${MM_SENTRY_DSN_DEV:-}"
123
140
  export EXPO_NO_TYPESCRIPT_SETUP=1
124
141
  export WATCHER_PORT="$(printf '%q' "$PORT")" METRO_PORT="$(printf '%q' "$PORT")"
125
142
  export METRO_MAX_WORKERS="$(printf '%q' "$METRO_WORKERS")"
126
- exec yarn expo start --port "$(printf '%q' "$PORT")"$clear_flag >> "$(printf '%q' "$LOG_FILE")" 2>&1
143
+ yarn expo start --port "$(printf '%q' "$PORT")"$clear_flag 2>&1 | tee -a "$(printf '%q' "$LOG_FILE")"
127
144
  EOF
128
145
  chmod +x "$runner"
129
146
  }
@@ -203,6 +220,10 @@ if ! start_metro_tmux; then
203
220
  # rejects. Default to the main dev client (what runway installs / launch targets)
204
221
  # so already-installed slots work without re-syncing fixtures.
205
222
  [ -n "${METAMASK_BUILD_TYPE:-}" ] || export METAMASK_BUILD_TYPE=main
223
+ # build.sh remaps MM_SENTRY_DSN from MM_SENTRY_DSN_DEV for dev builds (start:ios
224
+ # = build.sh ios main dev); quick-launch bypasses build.sh, so without this the
225
+ # dev client's MM_SENTRY_DSN stays empty and Sentry never initializes.
226
+ [ -n "${MM_SENTRY_DSN:-}" ] || export MM_SENTRY_DSN="${MM_SENTRY_DSN_DEV:-}"
206
227
  export EXPO_NO_TYPESCRIPT_SETUP=1
207
228
  export WATCHER_PORT="${PORT}" METRO_PORT="${PORT}"
208
229
  export METRO_MAX_WORKERS="${METRO_WORKERS}"
File without changes