@deeeed/metamask-harness 0.12.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 +9 -1
- package/adapters/mobile/bridge-runtime/cdp-bridge.cjs +16 -0
- package/adapters/mobile/bridge-runtime/console-forwarder.cjs +2 -4
- 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 +17 -13
- package/adapters/mobile/bridge-runtime/lib/ws-client.cjs +10 -5
- package/adapters/mobile/start-metro.sh +7 -0
- package/adapters/mobile/wait-for-bridge.sh +41 -23
- package/dist/adapters/mobile/prepare.js +23 -2
- package/dist/adapters/mobile/runtime-decision.js +2 -1
- package/dist/adapters.js +33 -10
- 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/library/actions/mobile/platform/bridge.mjs +35 -4
- package/package.json +1 -1
package/CHANGELOG.md
CHANGED
|
@@ -1,9 +1,17 @@
|
|
|
1
1
|
# Changelog
|
|
2
2
|
|
|
3
|
-
##
|
|
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).
|
|
4
9
|
|
|
5
10
|
### Fixed
|
|
6
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.
|
|
7
15
|
|
|
8
16
|
## 0.12.0 - 2026-07-08
|
|
9
17
|
|
|
@@ -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
|
-
|
|
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
|
|
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
|
|
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
|
|
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
|
-
|
|
49
|
-
|
|
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
|
};
|
|
@@ -120,6 +120,9 @@ set -a
|
|
|
120
120
|
[ ! -f .env.local ] || . ./.env.local
|
|
121
121
|
set +a
|
|
122
122
|
[ -n "\${METAMASK_BUILD_TYPE:-}" ] || export METAMASK_BUILD_TYPE=main
|
|
123
|
+
# build.sh remaps MM_SENTRY_DSN from MM_SENTRY_DSN_DEV for dev builds; quick-launch
|
|
124
|
+
# bypasses it, so without this Sentry never initializes in the dev client.
|
|
125
|
+
[ -n "\${MM_SENTRY_DSN:-}" ] || export MM_SENTRY_DSN="\${MM_SENTRY_DSN_DEV:-}"
|
|
123
126
|
export EXPO_NO_TYPESCRIPT_SETUP=1
|
|
124
127
|
export WATCHER_PORT="$(printf '%q' "$PORT")" METRO_PORT="$(printf '%q' "$PORT")"
|
|
125
128
|
export METRO_MAX_WORKERS="$(printf '%q' "$METRO_WORKERS")"
|
|
@@ -203,6 +206,10 @@ if ! start_metro_tmux; then
|
|
|
203
206
|
# rejects. Default to the main dev client (what runway installs / launch targets)
|
|
204
207
|
# so already-installed slots work without re-syncing fixtures.
|
|
205
208
|
[ -n "${METAMASK_BUILD_TYPE:-}" ] || export METAMASK_BUILD_TYPE=main
|
|
209
|
+
# build.sh remaps MM_SENTRY_DSN from MM_SENTRY_DSN_DEV for dev builds (start:ios
|
|
210
|
+
# = build.sh ios main dev); quick-launch bypasses build.sh, so without this the
|
|
211
|
+
# dev client's MM_SENTRY_DSN stays empty and Sentry never initializes.
|
|
212
|
+
[ -n "${MM_SENTRY_DSN:-}" ] || export MM_SENTRY_DSN="${MM_SENTRY_DSN_DEV:-}"
|
|
206
213
|
export EXPO_NO_TYPESCRIPT_SETUP=1
|
|
207
214
|
export WATCHER_PORT="${PORT}" METRO_PORT="${PORT}"
|
|
208
215
|
export METRO_MAX_WORKERS="${METRO_WORKERS}"
|
|
@@ -9,6 +9,8 @@
|
|
|
9
9
|
# --target <metamask-mobile dir> (default $PWD)
|
|
10
10
|
# --port <number> (default WATCHER_PORT env, else METRO_PORT, else 8081)
|
|
11
11
|
# --max-polls <n> (default MOBILE_BRIDGE_READY_POLLS env, else 90; 2s each = 180s)
|
|
12
|
+
# --platform ios|android bind the required platform: the answering target
|
|
13
|
+
# must belong to it, regardless of ambient device env
|
|
12
14
|
# Outputs:
|
|
13
15
|
# Progress on stderr; exit 0 bridge ready; 1 timeout.
|
|
14
16
|
#
|
|
@@ -18,14 +20,16 @@ set -uo pipefail
|
|
|
18
20
|
TARGET="$PWD"
|
|
19
21
|
PORT="${WATCHER_PORT:-${METRO_PORT:-8081}}"
|
|
20
22
|
MAX_POLLS="${MOBILE_BRIDGE_READY_POLLS:-90}"
|
|
23
|
+
REQUIRE_PLATFORM=""
|
|
21
24
|
|
|
22
25
|
while [ "$#" -gt 0 ]; do
|
|
23
26
|
case "$1" in
|
|
24
27
|
--target) TARGET="$2"; shift 2 ;;
|
|
25
28
|
--port) PORT="$2"; shift 2 ;;
|
|
26
29
|
--max-polls) MAX_POLLS="$2"; shift 2 ;;
|
|
30
|
+
--platform) REQUIRE_PLATFORM="$2"; shift 2 ;;
|
|
27
31
|
-h|--help)
|
|
28
|
-
printf 'Usage: wait-for-bridge.sh [--target <dir>] [--port <n>] [--max-polls <n>]\n'
|
|
32
|
+
printf 'Usage: wait-for-bridge.sh [--target <dir>] [--port <n>] [--max-polls <n>] [--platform ios|android]\n'
|
|
29
33
|
exit 0
|
|
30
34
|
;;
|
|
31
35
|
*) printf 'wait-for-bridge: unknown arg: %s\n' "$1" >&2; exit 2 ;;
|
|
@@ -34,6 +38,10 @@ done
|
|
|
34
38
|
|
|
35
39
|
TARGET="$(cd "$TARGET" && pwd)"
|
|
36
40
|
|
|
41
|
+
# The matcher reads the required platform from the environment so the value survives
|
|
42
|
+
# into the node subprocess that runs the shared matcher against the status output.
|
|
43
|
+
export WAIT_FOR_BRIDGE_PLATFORM="$REQUIRE_PLATFORM"
|
|
44
|
+
|
|
37
45
|
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
|
|
38
46
|
# shellcheck disable=SC1091
|
|
39
47
|
. "$SCRIPT_DIR/../shared/harness-path.sh"
|
|
@@ -45,6 +53,7 @@ LOG_DIR="$TARGET/$(recipe_runtime_dir)" || exit 1
|
|
|
45
53
|
mkdir -p "$LOG_DIR"
|
|
46
54
|
|
|
47
55
|
BRIDGE_CJS="$(dirname "$0")/bridge-runtime/cdp-bridge.cjs"
|
|
56
|
+
MATCH_LIB="$(dirname "$0")/bridge-runtime/lib/match-bridge-target.cjs"
|
|
48
57
|
STATUS_LOG="$LOG_DIR/bridge-status.log"
|
|
49
58
|
METRO_LOG="$LOG_DIR/metro.log"
|
|
50
59
|
|
|
@@ -86,30 +95,37 @@ bridge_wait_reason() {
|
|
|
86
95
|
bundle_progress "$METRO_LOG"
|
|
87
96
|
}
|
|
88
97
|
|
|
98
|
+
# Matcher is the shared boundary-safe module (match-bridge-target.cjs), so the
|
|
99
|
+
# launch ready-path confirm and this poll enforce identical platform/device rules.
|
|
89
100
|
bridge_has_route() {
|
|
90
101
|
(cd "$TARGET" && APP_ROOT="$TARGET" node "$BRIDGE_CJS" status > "$STATUS_LOG" 2>&1) \
|
|
91
|
-
&& node -
|
|
92
|
-
const
|
|
102
|
+
&& node -e '
|
|
103
|
+
const { hasMatchingRoute } = require(process.argv[1]);
|
|
104
|
+
const fs = require("fs");
|
|
93
105
|
try {
|
|
94
|
-
const value = JSON.parse(fs.readFileSync(process.argv[2],
|
|
95
|
-
|
|
96
|
-
const androidName = process.env.ANDROID_TARGET_DEVICE_NAME || process.env.ANDROID_DEVICE || '';
|
|
97
|
-
const adbSerial = process.env.ADB_SERIAL || process.env.ANDROID_SERIAL || '';
|
|
98
|
-
const iosSimulator = process.env.IOS_SIMULATOR || '';
|
|
99
|
-
const matchesTarget = (target) => {
|
|
100
|
-
if (!target || typeof target !== 'object') return false;
|
|
101
|
-
if (adbSerial || androidName) {
|
|
102
|
-
if (target.platform !== 'android') return false;
|
|
103
|
-
if (!androidName) return true;
|
|
104
|
-
const deviceName = String(target.deviceName || '');
|
|
105
|
-
return deviceName === androidName || deviceName.startsWith(`${androidName} -`);
|
|
106
|
-
}
|
|
107
|
-
if (iosSimulator) return target.deviceName === iosSimulator;
|
|
108
|
-
return true;
|
|
109
|
-
};
|
|
110
|
-
if (!targets.some((t) => matchesTarget(t) && t.route)) process.exit(1);
|
|
106
|
+
const value = JSON.parse(fs.readFileSync(process.argv[2], "utf8"));
|
|
107
|
+
process.exit(hasMatchingRoute(value) ? 0 : 1);
|
|
111
108
|
} catch { process.exit(1); }
|
|
112
|
-
|
|
109
|
+
' "$MATCH_LIB" "$STATUS_LOG"
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
# The device/platform this run is pinned to — the bridge target must belong to it.
|
|
113
|
+
requested_target() {
|
|
114
|
+
node -e 'process.stdout.write(require(process.argv[1]).describeRequested())' "$MATCH_LIB" 2>/dev/null \
|
|
115
|
+
|| printf 'any platform'
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
# Summarise the targets that DID answer (platform / deviceName), so a timeout teaches
|
|
119
|
+
# what responded vs what was requested — e.g. an iOS target answering an android launch.
|
|
120
|
+
answered_targets() {
|
|
121
|
+
[ -s "$STATUS_LOG" ] || { printf 'none'; return; }
|
|
122
|
+
node -e '
|
|
123
|
+
const { describeTargets } = require(process.argv[1]);
|
|
124
|
+
const fs = require("fs");
|
|
125
|
+
try {
|
|
126
|
+
process.stdout.write(describeTargets(JSON.parse(fs.readFileSync(process.argv[2], "utf8"))));
|
|
127
|
+
} catch { process.stdout.write("none"); }
|
|
128
|
+
' "$MATCH_LIB" "$STATUS_LOG" 2>/dev/null || printf 'none'
|
|
113
129
|
}
|
|
114
130
|
|
|
115
131
|
for ATTEMPT in $(seq 1 "$MAX_POLLS"); do
|
|
@@ -124,7 +140,9 @@ for ATTEMPT in $(seq 1 "$MAX_POLLS"); do
|
|
|
124
140
|
done
|
|
125
141
|
|
|
126
142
|
cat "$STATUS_LOG" >&2 || true
|
|
127
|
-
printf 'wait-for-bridge: bridge
|
|
128
|
-
"$PORT" "$MAX_POLLS" "$((MAX_POLLS * 2))" >&2
|
|
143
|
+
printf 'wait-for-bridge: no bridge target matched the requested %s on port %s (%s polls × 2s = %ss)\n' \
|
|
144
|
+
"$(requested_target)" "$PORT" "$MAX_POLLS" "$((MAX_POLLS * 2))" >&2
|
|
145
|
+
printf ' requested: %s\n' "$(requested_target)" >&2
|
|
146
|
+
printf ' answered: %s\n' "$(answered_targets)" >&2
|
|
129
147
|
printf ' Next: check %s — or run mm-harness app-status\n' "$STATUS_LOG" >&2
|
|
130
148
|
exit 1
|
|
@@ -3,8 +3,10 @@ import { recordDepsBaseline } from "@farmslot/recipe-harness/runtime/deps-readin
|
|
|
3
3
|
import { EXIT, spawnScriptStreaming } from "../../commands/shared.js";
|
|
4
4
|
import { runnerDir } from "../../paths.js";
|
|
5
5
|
import {
|
|
6
|
-
decideMobileReadiness
|
|
6
|
+
decideMobileReadiness,
|
|
7
|
+
launchActions
|
|
7
8
|
} from "./runtime-decision.js";
|
|
9
|
+
const READY_BRIDGE_CONFIRM_POLLS = 3;
|
|
8
10
|
const POD_PROBE_ENV = {
|
|
9
11
|
FORCE_COLOR: "0",
|
|
10
12
|
NO_COLOR: "1",
|
|
@@ -42,6 +44,24 @@ async function prepareMobile(target, opts = {}) {
|
|
|
42
44
|
return { status: EXIT.runtime, output: msg };
|
|
43
45
|
}
|
|
44
46
|
if (report.decision === "ready") {
|
|
47
|
+
const confirm = await dispatchAction(
|
|
48
|
+
{ id: "wait-for-bridge", cwd: target, argv: ["--max-polls", String(READY_BRIDGE_CONFIRM_POLLS)] },
|
|
49
|
+
target,
|
|
50
|
+
platform,
|
|
51
|
+
json,
|
|
52
|
+
preflightMode
|
|
53
|
+
);
|
|
54
|
+
if (confirm.status === 0) return { status: 0, output: "" };
|
|
55
|
+
if (!json) {
|
|
56
|
+
process.stderr.write(
|
|
57
|
+
`launch: runtime looked ready but no ${platform} bridge target answered; launching the app.
|
|
58
|
+
`
|
|
59
|
+
);
|
|
60
|
+
}
|
|
61
|
+
for (const action of launchActions(path.resolve(target))) {
|
|
62
|
+
const result = await dispatchAction(action, target, platform, json, preflightMode);
|
|
63
|
+
if (result.status !== 0) return result;
|
|
64
|
+
}
|
|
45
65
|
return { status: 0, output: "" };
|
|
46
66
|
}
|
|
47
67
|
if (report.decision === "unknown") {
|
|
@@ -115,7 +135,8 @@ async function dispatchAction(action, target, platform, json, preflightMode = "f
|
|
|
115
135
|
}
|
|
116
136
|
case "wait-for-bridge": {
|
|
117
137
|
const leaf = path.join(runnerDir, "adapters/mobile/wait-for-bridge.sh");
|
|
118
|
-
|
|
138
|
+
const extra = action.argv ?? [];
|
|
139
|
+
return spawnScriptStreaming(leaf, ["--target", cwd, "--platform", platform, ...extra], target);
|
|
119
140
|
}
|
|
120
141
|
case "clear-metro-cache": {
|
|
121
142
|
const leaf = path.join(runnerDir, "adapters/mobile/start-metro.sh");
|
package/dist/adapters.js
CHANGED
|
@@ -2,7 +2,7 @@ import http from "node:http";
|
|
|
2
2
|
import { compatibilityMode, fixtureSummary, repoShape } from "./doctor.js";
|
|
3
3
|
import { runLiveAdapterScript } from "./live-adapter-contract.js";
|
|
4
4
|
import { withExtensionPage } from "../library/actions/extension/platform/cdp.mjs";
|
|
5
|
-
import { bridgeCommand, evalSync, selectBridgeStatusEntry, simulatorScreenshot } from "../library/actions/mobile/platform/bridge.mjs";
|
|
5
|
+
import { bridgeCommand, evalSync, MOBILE_BRIDGE_ERROR_CODES, selectBridgeStatusEntry, simulatorScreenshot } from "../library/actions/mobile/platform/bridge.mjs";
|
|
6
6
|
function sleep(ms) {
|
|
7
7
|
return new Promise((resolve) => setTimeout(resolve, ms));
|
|
8
8
|
}
|
|
@@ -331,9 +331,21 @@ async function handleMobileHud(payload, context) {
|
|
|
331
331
|
return mobileHudSkippedOrThrow(error, { nodeId: hud.nodeId, status: hud.status });
|
|
332
332
|
}
|
|
333
333
|
}
|
|
334
|
+
async function hideMobileHudOnTeardown(projectRoot, env = {}) {
|
|
335
|
+
if (process.env.METAMASK_RECIPE_AUTO_HUD === "0" || process.env.METAMASK_RECIPE_AUTO_HUD === "false") return;
|
|
336
|
+
const input = {
|
|
337
|
+
node: { bridge_timeout_ms: 8e3, cdp_timeout_ms: 5e3 },
|
|
338
|
+
context: { nodeId: "teardown", projectRoot, artifactsDir: projectRoot, env }
|
|
339
|
+
};
|
|
340
|
+
try {
|
|
341
|
+
await bridgeCommand(input, ["hide-step"]);
|
|
342
|
+
} catch {
|
|
343
|
+
}
|
|
344
|
+
}
|
|
334
345
|
function mobileHudSkippedOrThrow(error, extra = {}) {
|
|
335
346
|
const reason = traceText(error);
|
|
336
|
-
|
|
347
|
+
const code = isRecord(error) && typeof error.code === "string" ? error.code : void 0;
|
|
348
|
+
if (isMobileHudLifecycleSkip(reason, code)) {
|
|
337
349
|
process.stderr.write(`app.hud skipped (bridge target down or transitioning): ${reason}
|
|
338
350
|
`);
|
|
339
351
|
return {
|
|
@@ -346,20 +358,30 @@ function mobileHudSkippedOrThrow(error, extra = {}) {
|
|
|
346
358
|
}
|
|
347
359
|
throw new Error(`app.hud bridge command failed outside a lifecycle/no-target transition: ${reason}`);
|
|
348
360
|
}
|
|
349
|
-
|
|
361
|
+
const HUD_LIFECYCLE_SKIP_CODES = /* @__PURE__ */ new Set([
|
|
362
|
+
MOBILE_BRIDGE_ERROR_CODES.NO_TARGET,
|
|
363
|
+
MOBILE_BRIDGE_ERROR_CODES.CDP_TIMEOUT,
|
|
364
|
+
MOBILE_BRIDGE_ERROR_CODES.WS_CLOSED,
|
|
365
|
+
MOBILE_BRIDGE_ERROR_CODES.METRO_UNREACHABLE
|
|
366
|
+
]);
|
|
367
|
+
function isMobileHudLifecycleSkip(reason, code) {
|
|
368
|
+
if (code && HUD_LIFECYCLE_SKIP_CODES.has(code)) return true;
|
|
369
|
+
const haystack = reason.toLowerCase();
|
|
350
370
|
return [
|
|
351
371
|
"no responding bridge target",
|
|
352
|
-
"
|
|
372
|
+
"pinned android device",
|
|
353
373
|
"bridge probe failed",
|
|
354
|
-
"
|
|
374
|
+
"econnrefused",
|
|
355
375
|
"timed out",
|
|
356
|
-
"
|
|
357
|
-
|
|
376
|
+
"no react native bridge target",
|
|
377
|
+
// Metro down mid-run: HUD cannot paint, so skip rather than abort (the real
|
|
378
|
+
// action fails on its own). Matches the METRO_UNREACHABLE code branch above.
|
|
379
|
+
"cannot reach metro",
|
|
358
380
|
// Dev-client foreground/reload kills the Hermes page mid-flight: pending
|
|
359
381
|
// CDP calls surface as a message timeout or a closed socket.
|
|
360
|
-
"
|
|
361
|
-
"
|
|
362
|
-
].some((needle) =>
|
|
382
|
+
"cdp message timeout",
|
|
383
|
+
"websocket closed"
|
|
384
|
+
].some((needle) => haystack.includes(needle));
|
|
363
385
|
}
|
|
364
386
|
function animatedFlag(payload) {
|
|
365
387
|
return payload.animated === true ? "--animated" : "--no-animated";
|
|
@@ -511,5 +533,6 @@ function targetProbeUrl(platform, targetPort) {
|
|
|
511
533
|
export {
|
|
512
534
|
createMetaMaskAdapters,
|
|
513
535
|
createMetaMaskUiTransport,
|
|
536
|
+
hideMobileHudOnTeardown,
|
|
514
537
|
isMobileHudLifecycleSkip
|
|
515
538
|
};
|
package/dist/commands/call.js
CHANGED
|
@@ -6,8 +6,10 @@ import { loadActionManifest } from "../manifest.js";
|
|
|
6
6
|
import { importRecipeProtocol } from "../paths.js";
|
|
7
7
|
import { recipeRunning } from "../heal-bounds.js";
|
|
8
8
|
import { EXIT } from "./shared.js";
|
|
9
|
+
import { describeManifestActions, fuzzyResolveActions } from "./manifest.js";
|
|
9
10
|
import {
|
|
10
11
|
parseArgs,
|
|
12
|
+
isRecord,
|
|
11
13
|
optionFlag,
|
|
12
14
|
optionString,
|
|
13
15
|
resolveAdapter,
|
|
@@ -153,6 +155,82 @@ Artifacts: ${result.artifactManifestPath}`);
|
|
|
153
155
|
}
|
|
154
156
|
return result.status === "pass" ? EXIT.ok : EXIT.runtime;
|
|
155
157
|
}
|
|
158
|
+
async function handleCallHelp(argv, genericHelp) {
|
|
159
|
+
const { action: shortName, rest } = parseCallArgs(argv.filter((arg) => arg !== "--help" && arg !== "-h"));
|
|
160
|
+
const { options } = parseArgs(rest, "call");
|
|
161
|
+
let adapter;
|
|
162
|
+
let manifest;
|
|
163
|
+
try {
|
|
164
|
+
({ adapter } = resolveAdapter(options));
|
|
165
|
+
manifest = loadActionManifest(adapter, optionString(options, "actionManifest"));
|
|
166
|
+
} catch {
|
|
167
|
+
process.stdout.write(`${genericHelp}
|
|
168
|
+
`);
|
|
169
|
+
return EXIT.ok;
|
|
170
|
+
}
|
|
171
|
+
const matches = shortName ? fuzzyResolveActions(describeManifestActions(manifest), shortName) : [];
|
|
172
|
+
if (matches.length === 0) {
|
|
173
|
+
process.stdout.write(`${genericHelp}
|
|
174
|
+
`);
|
|
175
|
+
if (shortName) {
|
|
176
|
+
process.stdout.write(
|
|
177
|
+
`
|
|
178
|
+
No action matches "${shortName}" for the ${adapter} adapter.
|
|
179
|
+
Next: mm-harness actions --adapter ${adapter} # list the action vocabulary
|
|
180
|
+
`
|
|
181
|
+
);
|
|
182
|
+
}
|
|
183
|
+
return EXIT.ok;
|
|
184
|
+
}
|
|
185
|
+
for (const entry of matches) process.stdout.write(renderCallActionHelp(entry));
|
|
186
|
+
process.stdout.write(`${genericHelp}
|
|
187
|
+
`);
|
|
188
|
+
return EXIT.ok;
|
|
189
|
+
}
|
|
190
|
+
function renderCallActionHelp(entry) {
|
|
191
|
+
const short = entry.name.split(".").pop() ?? entry.name;
|
|
192
|
+
const schema = isRecord(entry.schema) ? entry.schema : {};
|
|
193
|
+
const properties = isRecord(schema.properties) ? schema.properties : {};
|
|
194
|
+
const required = new Set(
|
|
195
|
+
Array.isArray(schema.required) ? schema.required.filter((r) => typeof r === "string") : []
|
|
196
|
+
);
|
|
197
|
+
const lines = [`mm-harness call ${entry.name} [--arg k=v ...] [flags]`, ""];
|
|
198
|
+
if (entry.description) lines.push(` ${entry.description}`, "");
|
|
199
|
+
const names = Object.keys(properties).sort();
|
|
200
|
+
if (names.length === 0) {
|
|
201
|
+
lines.push(" Fields: (none)");
|
|
202
|
+
} else {
|
|
203
|
+
lines.push(" Fields (pass with --arg <name>=<value>):");
|
|
204
|
+
const width = Math.max(...names.map((name) => name.length));
|
|
205
|
+
for (const name of names) {
|
|
206
|
+
const prop = isRecord(properties[name]) ? properties[name] : {};
|
|
207
|
+
const type = typeof prop.type === "string" ? prop.type : "any";
|
|
208
|
+
const req = required.has(name) ? " (required)" : "";
|
|
209
|
+
const desc = typeof prop.description === "string" ? ` \u2014 ${prop.description}` : "";
|
|
210
|
+
const enumVals = Array.isArray(prop.enum) ? ` [one of: ${prop.enum.join(", ")}]` : "";
|
|
211
|
+
lines.push(` ${name.padEnd(width)} ${type}${req}${desc}${enumVals}`);
|
|
212
|
+
}
|
|
213
|
+
}
|
|
214
|
+
const examples = renderCallExamples(short, entry.examples);
|
|
215
|
+
if (examples.length > 0) {
|
|
216
|
+
lines.push("", " Examples:");
|
|
217
|
+
for (const example of examples) lines.push(` ${example}`);
|
|
218
|
+
}
|
|
219
|
+
return `${lines.join("\n")}
|
|
220
|
+
|
|
221
|
+
`;
|
|
222
|
+
}
|
|
223
|
+
function renderCallExamples(short, examples) {
|
|
224
|
+
if (!Array.isArray(examples)) return [];
|
|
225
|
+
const out = [];
|
|
226
|
+
for (const example of examples.slice(0, 2)) {
|
|
227
|
+
const node = isRecord(example) && isRecord(example.node) ? example.node : void 0;
|
|
228
|
+
if (!node) continue;
|
|
229
|
+
const tokens = Object.entries(node).filter(([key]) => key !== "action" && key !== "intent").map(([key, value]) => `--arg ${key}=${typeof value === "string" ? value : JSON.stringify(value)}`);
|
|
230
|
+
out.push(`mm-harness call ${short} ${tokens.join(" ")}`.trim());
|
|
231
|
+
}
|
|
232
|
+
return out;
|
|
233
|
+
}
|
|
156
234
|
function parseCallArgs(argv) {
|
|
157
235
|
const args = {};
|
|
158
236
|
const rest = [];
|
|
@@ -191,5 +269,6 @@ function pickCallExampleAction(names) {
|
|
|
191
269
|
return names[0] ?? "command";
|
|
192
270
|
}
|
|
193
271
|
export {
|
|
194
|
-
handleCall
|
|
272
|
+
handleCall,
|
|
273
|
+
handleCallHelp
|
|
195
274
|
};
|
|
@@ -68,7 +68,15 @@ async function runRecipe(adapter, recipe, artifactsDir, projectRoot, actionManif
|
|
|
68
68
|
recordVideo: useFramedExtensionRecording ? false : recordVideo,
|
|
69
69
|
...runtimeOptions.librarySources ? { librarySources: runtimeOptions.librarySources } : {}
|
|
70
70
|
};
|
|
71
|
-
|
|
71
|
+
let result;
|
|
72
|
+
try {
|
|
73
|
+
result = await runner.run(runRequest);
|
|
74
|
+
} finally {
|
|
75
|
+
if (adapter === "mobile") {
|
|
76
|
+
const { hideMobileHudOnTeardown } = await import("../adapters.js");
|
|
77
|
+
await hideMobileHudOnTeardown(projectRoot, recipeRunEnv(adapter, runtimeOptions));
|
|
78
|
+
}
|
|
79
|
+
}
|
|
72
80
|
await stopRecipeRecording(recording, result);
|
|
73
81
|
return result;
|
|
74
82
|
} finally {
|
package/dist/mm-harness-cli.js
CHANGED
|
@@ -5,6 +5,7 @@ import { fileURLToPath } from "node:url";
|
|
|
5
5
|
import { Command } from "commander";
|
|
6
6
|
import { color } from "./cli-color.js";
|
|
7
7
|
import { handleUpdate, maybeNudge } from "./commands/update.js";
|
|
8
|
+
import { handleCallHelp } from "./commands/call.js";
|
|
8
9
|
const packageRoot = path.resolve(path.dirname(fileURLToPath(import.meta.url)), "..");
|
|
9
10
|
globalThis.__MM_HARNESS_WRAPPER__ = true;
|
|
10
11
|
const { main: recipeMain } = await import("./cli.js");
|
|
@@ -614,6 +615,13 @@ function hasPassthroughHelp(argv) {
|
|
|
614
615
|
if (divider === -1) return false;
|
|
615
616
|
return argv.slice(divider + 1).some((arg) => arg === "-h" || arg === "--help");
|
|
616
617
|
}
|
|
618
|
+
function isCallActionHelp(argv) {
|
|
619
|
+
if (argv[0] !== "call") return false;
|
|
620
|
+
if (!argv[1] || argv[1].startsWith("-")) return false;
|
|
621
|
+
const divider = argv.indexOf("--");
|
|
622
|
+
const scope = divider === -1 ? argv : argv.slice(0, divider);
|
|
623
|
+
return scope.includes("--help") || scope.includes("-h");
|
|
624
|
+
}
|
|
617
625
|
if (rawArgv.length === 0) {
|
|
618
626
|
process.stdout.write(groupedHelp());
|
|
619
627
|
process.exit(0);
|
|
@@ -621,4 +629,8 @@ if (rawArgv.length === 0) {
|
|
|
621
629
|
if (hasPassthroughHelp(rawArgv)) {
|
|
622
630
|
process.exit(await delegate(rawArgv));
|
|
623
631
|
}
|
|
632
|
+
if (isCallActionHelp(rawArgv)) {
|
|
633
|
+
const callHelp = REAL.find((command) => command.name === "call")?.helpText ?? "";
|
|
634
|
+
process.exit(await handleCallHelp(rawArgv.slice(1), callHelp));
|
|
635
|
+
}
|
|
624
636
|
await program.parseAsync(process.argv);
|
|
@@ -3,9 +3,35 @@ import { execFile, spawn } from 'node:child_process';
|
|
|
3
3
|
import { promisify } from 'node:util';
|
|
4
4
|
import path from 'node:path';
|
|
5
5
|
import { fileURLToPath } from 'node:url';
|
|
6
|
+
import bridgeErrors from '../../../../adapters/mobile/bridge-runtime/lib/bridge-errors.cjs';
|
|
7
|
+
|
|
8
|
+
const {
|
|
9
|
+
BRIDGE_ERROR_CODES,
|
|
10
|
+
ERROR_CODE_BY_EXIT_CODE,
|
|
11
|
+
classifyBridgeErrorMessage,
|
|
12
|
+
coded,
|
|
13
|
+
parseErrorMarker,
|
|
14
|
+
} = bridgeErrors;
|
|
15
|
+
|
|
16
|
+
// Re-exported so the TS adapter classifies on the same code constants without a
|
|
17
|
+
// second import path into the cjs bridge-runtime.
|
|
18
|
+
export const MOBILE_BRIDGE_ERROR_CODES = BRIDGE_ERROR_CODES;
|
|
6
19
|
|
|
7
20
|
const execFileAsync = promisify(execFile);
|
|
8
21
|
|
|
22
|
+
// Recover the typed code a cdp-bridge child reported: the stderr marker is
|
|
23
|
+
// authoritative (it carries the class the bridge classified at the source), the
|
|
24
|
+
// coded exit status is the fallback for output-less failures, and a raw message
|
|
25
|
+
// needle covers a bridge that predates the codes.
|
|
26
|
+
function bridgeChildErrorCode(exitCode, output) {
|
|
27
|
+
return (
|
|
28
|
+
parseErrorMarker(output) ||
|
|
29
|
+
ERROR_CODE_BY_EXIT_CODE[exitCode] ||
|
|
30
|
+
classifyBridgeErrorMessage(output) ||
|
|
31
|
+
null
|
|
32
|
+
);
|
|
33
|
+
}
|
|
34
|
+
|
|
9
35
|
function sleep(ms) {
|
|
10
36
|
return new Promise((resolve) => setTimeout(resolve, ms));
|
|
11
37
|
}
|
|
@@ -169,12 +195,12 @@ export function selectBridgeStatusEntry(status, input) {
|
|
|
169
195
|
}
|
|
170
196
|
// Pinned android but no android entry answered — failing fast beats letting the
|
|
171
197
|
// caller read the iOS entry and report misleading wallet state.
|
|
172
|
-
throw new Error(
|
|
198
|
+
throw coded(new Error(
|
|
173
199
|
`Pinned android device ${adbSerial} has no responding bridge target.\n` +
|
|
174
200
|
` Bridge status entries:\n` +
|
|
175
201
|
entries.map((entry) => ` - ${entry.deviceName ?? '?'} [${entry.platform || 'unknown'}]`).join('\n') +
|
|
176
202
|
`\n Next: mm-harness launch android # bring the app back to the foreground`,
|
|
177
|
-
);
|
|
203
|
+
), BRIDGE_ERROR_CODES.NO_TARGET);
|
|
178
204
|
}
|
|
179
205
|
if (iosSimulator) {
|
|
180
206
|
const sim = entries.find((entry) => entry.deviceName === iosSimulator);
|
|
@@ -239,11 +265,16 @@ export async function bridgeCommand(input, args) {
|
|
|
239
265
|
});
|
|
240
266
|
if (result.timedOut) {
|
|
241
267
|
const command = ['node', path.relative(input.context.projectRoot, script), ...redactBridgeArgs(args)].join(' ');
|
|
242
|
-
throw
|
|
268
|
+
throw coded(
|
|
269
|
+
new Error(`Mobile CDP bridge command timed out after ${result.timeoutMs}ms: ${command}`),
|
|
270
|
+
BRIDGE_ERROR_CODES.CDP_TIMEOUT,
|
|
271
|
+
);
|
|
243
272
|
}
|
|
244
273
|
if (result.exitCode !== 0) {
|
|
245
274
|
const command = ['node', path.relative(input.context.projectRoot, script), ...redactBridgeArgs(args)].join(' ');
|
|
246
|
-
|
|
275
|
+
const code = bridgeChildErrorCode(result.exitCode, result.stderr || result.stdout);
|
|
276
|
+
const error = new Error(`Mobile CDP bridge command failed: ${command}\n${redactBridgeOutput(result.stderr || result.stdout, sensitiveBridgeArgs(args))}`);
|
|
277
|
+
throw code ? coded(error, code) : error;
|
|
247
278
|
}
|
|
248
279
|
// For transient commands, stdout of '' or 'undefined' means the state is not yet
|
|
249
280
|
// settled (e.g. mid-navigation). Return null so callers like waitForRoute can poll
|