@deeeed/metamask-harness 0.9.0 → 0.9.1
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 +8 -0
- package/adapters/mobile/bridge-runtime/cdp-bridge.cjs +18 -3
- package/adapters/mobile/bridge-runtime/lib/config.cjs +14 -2
- package/adapters/mobile/bridge-runtime/lib/target-discovery.cjs +70 -8
- package/adapters/mobile/start-metro.sh +23 -6
- package/docs/CLI-SPEC.md +3 -1
- package/library/actions/mobile/platform/bridge.mjs +81 -4
- package/library/actions/mobile/wallet/setup.mjs +5 -2
- package/package.json +1 -1
package/CHANGELOG.md
CHANGED
|
@@ -1,5 +1,13 @@
|
|
|
1
1
|
# Changelog
|
|
2
2
|
|
|
3
|
+
## 0.9.1 - 2026-07-07
|
|
4
|
+
|
|
5
|
+
### Fixed
|
|
6
|
+
- **Metro survives the launching shell.** `start-metro.sh` spawned Metro without SIGHUP protection, so closing the launching tmux window/shell killed it, leaving a stale `metro.pid` and hanging every subsequent bridge/fixtures call. Metro now starts under `nohup` (argv spawn, no `bash -c` string interpolation), and a stale `metro.pid` naming a dead process is detected and cleaned before start.
|
|
7
|
+
- **`--device <adb serial>` reaches CDP target selection.** The runner's ambiguity gate resolved the serial, but target discovery compared it against Metro's `deviceName` ("Pixel 6 - 16 - API 36") — never a serial — so with an iOS simulator also attached, a recipe pinned to the physical Android device could silently drive the simulator. The serial is now mapped to the Metro identity via `adb -s <serial> shell getprop ro.product.model` with model-prefix matching (scoped to non-simulator targets); an unmatchable or ambiguous pin (two same-model devices) fails fast listing every Metro `/json/list` candidate instead of silently picking one. `--device <serial>` remains the only thing users pass.
|
|
8
|
+
- **Bridge commands always emit valid JSON.** `get-route` printed the literal string `undefined` when the route was transiently unavailable mid-navigation, so `bridgeCommand()` threw on parse and `waitForRoute()` aborted instead of polling. `get-route`/`navigate`/`go-back` now normalise a missing route to `null`, `bridgeCommand()` treats `''`/`undefined` stdout as not-settled-yet only for transient-legitimate commands, and `waitForRoute()` polls through `null` until timeout — the timeout error carries the expected route, last parsed route, last bridge reply and the device pin.
|
|
9
|
+
- **Wallet setup env propagation.** `bridgeEnv()` became async with the serial mapping; the wallet setup action now awaits it instead of spreading a Promise, which would have handed `setup-wallet.sh` an almost-empty environment.
|
|
10
|
+
|
|
3
11
|
## 0.9.0 - 2026-07-07
|
|
4
12
|
|
|
5
13
|
### Added
|
|
@@ -104,7 +104,17 @@ const COMMANDS = {
|
|
|
104
104
|
client,
|
|
105
105
|
'globalThis.__AGENTIC__?.getRoute()',
|
|
106
106
|
);
|
|
107
|
-
|
|
107
|
+
// Normalize route values: cdpEval returns undefined when the optional-chain
|
|
108
|
+
// short-circuits (mid-navigation transient or bridge not yet installed).
|
|
109
|
+
// null is valid JSON; undefined produces the literal string "undefined".
|
|
110
|
+
return {
|
|
111
|
+
navigated: routeName,
|
|
112
|
+
params,
|
|
113
|
+
previousRoute: previousRoute ?? null,
|
|
114
|
+
currentRoute: currentRoute ?? null,
|
|
115
|
+
deviceName,
|
|
116
|
+
platform,
|
|
117
|
+
};
|
|
108
118
|
},
|
|
109
119
|
|
|
110
120
|
async 'get-route'(client) {
|
|
@@ -112,7 +122,10 @@ const COMMANDS = {
|
|
|
112
122
|
client,
|
|
113
123
|
'globalThis.__AGENTIC__?.getRoute()',
|
|
114
124
|
);
|
|
115
|
-
|
|
125
|
+
// cdpEval returns undefined when the optional-chain short-circuits (bridge not
|
|
126
|
+
// yet installed, mid-navigation, or route state transiently missing). Return null
|
|
127
|
+
// so JSON.stringify produces valid JSON ("null") instead of literal "undefined".
|
|
128
|
+
return route ?? null;
|
|
116
129
|
},
|
|
117
130
|
|
|
118
131
|
async 'get-state'(client, args) {
|
|
@@ -168,7 +181,9 @@ const COMMANDS = {
|
|
|
168
181
|
client,
|
|
169
182
|
'globalThis.__AGENTIC__?.getRoute()',
|
|
170
183
|
);
|
|
171
|
-
|
|
184
|
+
// Same normalization as navigate/get-route: a transiently-missing route must
|
|
185
|
+
// serialize as "currentRoute": null, not have the key silently omitted.
|
|
186
|
+
return { currentRoute: route ?? null, deviceName, platform };
|
|
172
187
|
},
|
|
173
188
|
|
|
174
189
|
async status(client, _args, { deviceName, platform } = {}) {
|
|
@@ -30,10 +30,22 @@ function loadSimulatorName() {
|
|
|
30
30
|
return loadEnvValue('IOS_SIMULATOR') || '';
|
|
31
31
|
}
|
|
32
32
|
|
|
33
|
-
/** Read ANDROID_DEVICE
|
|
33
|
+
/** Read ANDROID_DEVICE from .js.env or env (default: none — accept any device) */
|
|
34
34
|
function loadAndroidDevice() {
|
|
35
35
|
if ('ANDROID_DEVICE' in process.env) return process.env.ANDROID_DEVICE;
|
|
36
36
|
return loadEnvValue('ANDROID_DEVICE') || '';
|
|
37
37
|
}
|
|
38
38
|
|
|
39
|
-
|
|
39
|
+
/**
|
|
40
|
+
* Read ANDROID_TARGET_DEVICE_NAME from env — the Metro-compatible model prefix
|
|
41
|
+
* resolved from an adb serial by bridge.mjs. Used by target-discovery to select
|
|
42
|
+
* the correct Metro CDP target when multiple devices are connected.
|
|
43
|
+
*
|
|
44
|
+
* Set automatically by the bridge when --device <serial> is used. Users never
|
|
45
|
+
* need to set this; pass --device <adb-serial> and the harness maps it internally.
|
|
46
|
+
*/
|
|
47
|
+
function loadAndroidTargetDeviceName() {
|
|
48
|
+
return process.env.ANDROID_TARGET_DEVICE_NAME || '';
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
module.exports = { loadEnvValue, loadPort, loadSimulatorName, loadAndroidDevice, loadAndroidTargetDeviceName };
|
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
'use strict';
|
|
2
2
|
|
|
3
3
|
const http = require('node:http');
|
|
4
|
-
const { loadSimulatorName, loadAndroidDevice } = require('./config.cjs');
|
|
4
|
+
const { loadSimulatorName, loadAndroidDevice, loadAndroidTargetDeviceName } = require('./config.cjs');
|
|
5
5
|
const { createWSClient } = require('./ws-client.cjs');
|
|
6
6
|
|
|
7
7
|
const FETCH_TIMEOUT_MS = Number.parseInt(process.env.CDP_TIMEOUT || '30000', 10);
|
|
@@ -125,14 +125,76 @@ async function discoverTarget(port) {
|
|
|
125
125
|
}
|
|
126
126
|
}
|
|
127
127
|
|
|
128
|
-
//
|
|
128
|
+
// Android device filtering: support two identity layers.
|
|
129
|
+
// ANDROID_TARGET_DEVICE_NAME — Metro-compatible model prefix resolved by bridge.mjs
|
|
130
|
+
// from the adb serial via `adb -s <serial> shell getprop ro.product.model`.
|
|
131
|
+
// Example: "Pixel 6" matches Metro deviceName "Pixel 6 - 16 - API 36".
|
|
132
|
+
// ANDROID_DEVICE — exact Metro deviceName (backward compat / user-specified).
|
|
133
|
+
//
|
|
134
|
+
// When a device is pinned and multiple candidates remain, we MUST NOT silently fall
|
|
135
|
+
// back to another target (e.g. an iOS simulator). If the pin cannot be matched,
|
|
136
|
+
// fail fast with diagnostics so the operator can diagnose the mismatch.
|
|
137
|
+
const androidTargetName = loadAndroidTargetDeviceName();
|
|
129
138
|
const androidDevice = loadAndroidDevice();
|
|
130
|
-
|
|
131
|
-
|
|
132
|
-
|
|
133
|
-
|
|
134
|
-
|
|
135
|
-
|
|
139
|
+
const adbSerial = process.env.ADB_SERIAL || process.env.ANDROID_SERIAL || '';
|
|
140
|
+
|
|
141
|
+
if (candidates.length > 1) {
|
|
142
|
+
let androidFiltered = [];
|
|
143
|
+
|
|
144
|
+
if (androidTargetName) {
|
|
145
|
+
// Model prefix match: "Pixel 6" matches "Pixel 6 - 16 - API 36". Metro's
|
|
146
|
+
// /json/list has no explicit platform field, so scope the ANDROID pin by
|
|
147
|
+
// excluding the one iOS identity we do know (the pinned simulator name) —
|
|
148
|
+
// a simulator named to shadow a model string must not satisfy an android pin.
|
|
149
|
+
androidFiltered = candidates.filter(
|
|
150
|
+
(t) => t.deviceName !== simName &&
|
|
151
|
+
(t.deviceName === androidTargetName ||
|
|
152
|
+
(t.deviceName != null && t.deviceName.startsWith(androidTargetName))),
|
|
153
|
+
);
|
|
154
|
+
if (androidFiltered.length > 1) {
|
|
155
|
+
// Two same-model devices produce identical Metro model prefixes — the pin
|
|
156
|
+
// is genuinely ambiguous; picking one silently is exactly the bug this
|
|
157
|
+
// path exists to prevent.
|
|
158
|
+
const ambiguousList = androidFiltered
|
|
159
|
+
.map((t) => ` deviceName=${JSON.stringify(t.deviceName || '')} ws=${t.webSocketDebuggerUrl || ''}`)
|
|
160
|
+
.join('\n');
|
|
161
|
+
throw new Error(
|
|
162
|
+
`Pinned Android device is ambiguous: model '${androidTargetName}' matches ${androidFiltered.length} Metro targets.\n` +
|
|
163
|
+
` Requested --device (ADB_SERIAL): ${adbSerial || '(not set)'}\n` +
|
|
164
|
+
` Matching Metro targets:\n${ambiguousList}\n` +
|
|
165
|
+
` Set ANDROID_DEVICE to the exact Metro deviceName to disambiguate.`,
|
|
166
|
+
);
|
|
167
|
+
}
|
|
168
|
+
if (androidFiltered.length === 0) {
|
|
169
|
+
// Pinned device could not be matched — never silently pick another target.
|
|
170
|
+
const candidateList = targets
|
|
171
|
+
.map((t) => ` deviceName=${JSON.stringify(t.deviceName || '')} ws=${t.webSocketDebuggerUrl || ''}`)
|
|
172
|
+
.join('\n');
|
|
173
|
+
throw new Error(
|
|
174
|
+
`Pinned Android device did not match any Metro target.\n` +
|
|
175
|
+
` Requested --device (ADB_SERIAL): ${adbSerial || '(not set)'}\n` +
|
|
176
|
+
` Resolved model (ANDROID_TARGET_DEVICE_NAME): ${androidTargetName}\n` +
|
|
177
|
+
` ANDROID_DEVICE: ${androidDevice || '(not set)'}\n` +
|
|
178
|
+
` Metro /json/list candidates:\n${candidateList}`,
|
|
179
|
+
);
|
|
180
|
+
}
|
|
181
|
+
candidates = androidFiltered;
|
|
182
|
+
} else if (androidDevice) {
|
|
183
|
+
// Exact Metro deviceName match (user set ANDROID_DEVICE to the Metro name, or
|
|
184
|
+
// legacy path where ANDROID_DEVICE was not resolved from serial).
|
|
185
|
+
androidFiltered = candidates.filter((t) => t.deviceName === androidDevice);
|
|
186
|
+
if (androidFiltered.length === 0) {
|
|
187
|
+
// Pinned by exact Metro name but no candidate matched — fail fast.
|
|
188
|
+
const candidateList = targets
|
|
189
|
+
.map((t) => ` deviceName=${JSON.stringify(t.deviceName || '')} ws=${t.webSocketDebuggerUrl || ''}`)
|
|
190
|
+
.join('\n');
|
|
191
|
+
throw new Error(
|
|
192
|
+
`Pinned Android device (ANDROID_DEVICE='${androidDevice}') did not match any Metro target.\n` +
|
|
193
|
+
` ADB_SERIAL: ${adbSerial || '(not set)'}\n` +
|
|
194
|
+
` Metro /json/list candidates:\n${candidateList}`,
|
|
195
|
+
);
|
|
196
|
+
}
|
|
197
|
+
candidates = androidFiltered;
|
|
136
198
|
}
|
|
137
199
|
}
|
|
138
200
|
|
|
@@ -79,6 +79,18 @@ default_metro_workers() {
|
|
|
79
79
|
|
|
80
80
|
# --- main ---------------------------------------------------------------------
|
|
81
81
|
|
|
82
|
+
# Detect stale metro.pid: if the pid file names a dead process, clean it up.
|
|
83
|
+
# A stale file survives when Metro is killed externally (session close, SIGKILL)
|
|
84
|
+
# and the normal stop path never ran. Cleaning it here keeps metro_ready and the
|
|
85
|
+
# log from attributing a new start to the old process id.
|
|
86
|
+
if [ -f "$PID_FILE" ]; then
|
|
87
|
+
stale_pid="$(cat "$PID_FILE" 2>/dev/null || true)"
|
|
88
|
+
if [ -n "$stale_pid" ] && ! kill -0 "$stale_pid" 2>/dev/null; then
|
|
89
|
+
printf 'start-metro: stale metro.pid (pid %s no longer alive); cleaning up\n' "$stale_pid" >&2
|
|
90
|
+
rm -f "$PID_FILE"
|
|
91
|
+
fi
|
|
92
|
+
fi
|
|
93
|
+
|
|
82
94
|
# Decide whether to (re)start or skip.
|
|
83
95
|
if metro_ready; then
|
|
84
96
|
if [ "$CLEAR" = true ]; then
|
|
@@ -97,11 +109,11 @@ fi
|
|
|
97
109
|
stop_metro_listener || exit 1
|
|
98
110
|
|
|
99
111
|
METRO_WORKERS="${METRO_MAX_WORKERS:-$(default_metro_workers)}"
|
|
100
|
-
|
|
101
|
-
[ "$CLEAR" = true ] &&
|
|
112
|
+
CLEAR_LABEL=""
|
|
113
|
+
[ "$CLEAR" = true ] && CLEAR_LABEL=", clear"
|
|
102
114
|
|
|
103
115
|
printf 'Starting Metro on port %s (workers=%s%s)\n' \
|
|
104
|
-
"$PORT" "$METRO_WORKERS" "$
|
|
116
|
+
"$PORT" "$METRO_WORKERS" "$CLEAR_LABEL" >&2
|
|
105
117
|
printf '(Full log: %s — mm-harness logs | mm-harness logs --full)\n' "$LOG_FILE" >&2
|
|
106
118
|
|
|
107
119
|
# Metro runs detached, writing to the log. The tmux window is a read-only tail.
|
|
@@ -127,12 +139,17 @@ printf '(Full log: %s — mm-harness logs | mm-harness logs --full)\n' "$LOG_FIL
|
|
|
127
139
|
export WATCHER_PORT="${PORT}" METRO_PORT="${PORT}"
|
|
128
140
|
export METRO_MAX_WORKERS="${METRO_WORKERS}"
|
|
129
141
|
# shellcheck disable=SC2094
|
|
130
|
-
|
|
142
|
+
# nohup sets SIGHUP to ignored (inherited across exec), so Metro survives the
|
|
143
|
+
# launching shell or tmux window closing; when this subshell exits the process
|
|
144
|
+
# is reparented to init. That alone is the detach guarantee — Metro was never
|
|
145
|
+
# in the parent shell's job table, so there is nothing to disown.
|
|
146
|
+
# nohup.out is suppressed (stdout/stderr both go to $LOG_FILE via >>).
|
|
147
|
+
metro_args=(expo start --port "$PORT")
|
|
148
|
+
[ "$CLEAR" = true ] && metro_args+=(--clear)
|
|
149
|
+
nohup yarn "${metro_args[@]}" \
|
|
131
150
|
</dev/null >> "$LOG_FILE" 2>&1 &
|
|
132
151
|
echo "$!" > "$PID_FILE"
|
|
133
152
|
)
|
|
134
|
-
# Disown the background process so it outlives this script.
|
|
135
|
-
disown "$(cat "$PID_FILE" 2>/dev/null || true)" 2>/dev/null || true
|
|
136
153
|
|
|
137
154
|
start_viewer_window "$LOG_FILE"
|
|
138
155
|
|
package/docs/CLI-SPEC.md
CHANGED
|
@@ -439,7 +439,9 @@ Readiness check for a checkout without launching the app. Doctor is the single p
|
|
|
439
439
|
|
|
440
440
|
## `--device <id>` — first-class mobile device targeting (REAL)
|
|
441
441
|
|
|
442
|
-
`--device` is the uniform mobile device selector on `run`, `call`, and `doctor`.
|
|
442
|
+
`--device` is the uniform mobile device selector on `run`, `call`, and `doctor`. Pass the **adb serial** for Android (from `adb devices`) or the **UDID / simulator name** for iOS. The harness resolves the adb serial to the Metro CDP target identity internally — users never need to know or set `ANDROID_DEVICE='Pixel 6 - 16 - API 36'`.
|
|
443
|
+
|
|
444
|
+
**Internal Android identity mapping**: `ADB_SERIAL` / `ANDROID_SERIAL` carry the raw adb serial. The bridge resolves the device model via `adb -s <serial> shell getprop ro.product.model` and propagates it as `ANDROID_TARGET_DEVICE_NAME`. Target-discovery uses `ANDROID_TARGET_DEVICE_NAME` for Metro `deviceName` prefix matching (e.g. `"Pixel 6"` matches `"Pixel 6 - 16 - API 36"`). When the pinned model cannot be matched to any Metro `/json/list` candidate and multiple candidates exist, the bridge fails fast with a diagnostic listing every candidate's `deviceName` — it never silently selects the wrong device. iOS UDID/simulator name → `IOS_SIMULATOR` (unchanged).
|
|
443
445
|
|
|
444
446
|
| Verb | `--device` given | `--device` omitted (mobile) |
|
|
445
447
|
|---|---|---|
|
|
@@ -1,8 +1,11 @@
|
|
|
1
1
|
import { mkdir, readFile, writeFile } from 'node:fs/promises';
|
|
2
|
-
import { spawn } from 'node:child_process';
|
|
2
|
+
import { execFile, spawn } from 'node:child_process';
|
|
3
|
+
import { promisify } from 'node:util';
|
|
3
4
|
import path from 'node:path';
|
|
4
5
|
import { fileURLToPath } from 'node:url';
|
|
5
6
|
|
|
7
|
+
const execFileAsync = promisify(execFile);
|
|
8
|
+
|
|
6
9
|
function sleep(ms) {
|
|
7
10
|
return new Promise((resolve) => setTimeout(resolve, ms));
|
|
8
11
|
}
|
|
@@ -34,7 +37,30 @@ function runtimeDir() {
|
|
|
34
37
|
return fileURLToPath(new URL('../../../../adapters/mobile/bridge-runtime', import.meta.url));
|
|
35
38
|
}
|
|
36
39
|
|
|
37
|
-
|
|
40
|
+
/**
|
|
41
|
+
* Resolve the Android model name from an adb serial.
|
|
42
|
+
* Returns the trimmed ro.product.model value, or null when adb is unavailable
|
|
43
|
+
* or the serial does not respond. Uses execFile (never shell interpolation).
|
|
44
|
+
*/
|
|
45
|
+
async function resolveAndroidModel(adbSerial) {
|
|
46
|
+
try {
|
|
47
|
+
const { stdout } = await execFileAsync(
|
|
48
|
+
'adb',
|
|
49
|
+
['-s', adbSerial, 'shell', 'getprop', 'ro.product.model'],
|
|
50
|
+
{ timeout: 5000, encoding: 'utf8' },
|
|
51
|
+
);
|
|
52
|
+
const model = stdout.trim();
|
|
53
|
+
return model || null;
|
|
54
|
+
} catch {
|
|
55
|
+
// Recovery is correct: model resolution for Metro target selection is advisory.
|
|
56
|
+
// If adb is unavailable or the serial is unreachable, discovery falls through to
|
|
57
|
+
// the exact ANDROID_DEVICE match or probe-and-pick. The error will surface from
|
|
58
|
+
// the CDP connection attempt if the wrong target is selected.
|
|
59
|
+
return null;
|
|
60
|
+
}
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
export async function bridgeEnv(input) {
|
|
38
64
|
/** @type {NodeJS.ProcessEnv} */
|
|
39
65
|
const env = {
|
|
40
66
|
...process.env,
|
|
@@ -53,6 +79,20 @@ export function bridgeEnv(input) {
|
|
|
53
79
|
env.ADB_SERIAL = String(adbSerial);
|
|
54
80
|
env.ANDROID_SERIAL = String(adbSerial);
|
|
55
81
|
}
|
|
82
|
+
// When --device passes an adb serial, device-target.ts sets both ANDROID_DEVICE and
|
|
83
|
+
// ADB_SERIAL to the same value. ANDROID_DEVICE must be the Metro deviceName for
|
|
84
|
+
// target-discovery to select the right CDP target; it cannot be a raw adb serial.
|
|
85
|
+
// Resolve the device model via adb getprop and propagate it as
|
|
86
|
+
// ANDROID_TARGET_DEVICE_NAME so target-discovery can match by model prefix
|
|
87
|
+
// (e.g. "Pixel 6" matches Metro deviceName "Pixel 6 - 16 - API 36").
|
|
88
|
+
const serialStr = adbSerial != null ? String(adbSerial) : '';
|
|
89
|
+
const deviceStr = androidDevice != null ? String(androidDevice) : '';
|
|
90
|
+
if (serialStr && deviceStr === serialStr) {
|
|
91
|
+
const model = await resolveAndroidModel(serialStr);
|
|
92
|
+
if (model) {
|
|
93
|
+
env.ANDROID_TARGET_DEVICE_NAME = model;
|
|
94
|
+
}
|
|
95
|
+
}
|
|
56
96
|
return env;
|
|
57
97
|
}
|
|
58
98
|
|
|
@@ -65,13 +105,20 @@ function resolveMobileTarget(input) {
|
|
|
65
105
|
return { watcherPort, iosSimulator, androidDevice, adbSerial };
|
|
66
106
|
}
|
|
67
107
|
|
|
108
|
+
// Commands where a transient undefined/empty stdout means "not yet settled", not failure.
|
|
109
|
+
// get-route returns undefined mid-navigation when the route state is momentarily unavailable;
|
|
110
|
+
// waitForRoute polls through these nulls rather than aborting on a parse error.
|
|
111
|
+
const TRANSIENT_NULL_COMMANDS = new Set(['get-route']);
|
|
112
|
+
|
|
68
113
|
export async function bridgeCommand(input, args) {
|
|
69
114
|
const script = bridgeScript(input);
|
|
115
|
+
// bridgeEnv is async: it may call `adb getprop` to resolve the Metro device name.
|
|
116
|
+
const env = await bridgeEnv(input);
|
|
70
117
|
const result = await new Promise((resolve, reject) => {
|
|
71
118
|
const timeoutMs = Number(input.node?.bridge_timeout_ms ?? input.node?.cdp_timeout_ms ?? process.env.CDP_TIMEOUT ?? 30000);
|
|
72
119
|
const child = spawn(process.execPath, [script, ...args], {
|
|
73
120
|
cwd: input.context.projectRoot,
|
|
74
|
-
env: { ...
|
|
121
|
+
env: { ...env, APP_ROOT: input.context.projectRoot },
|
|
75
122
|
stdio: ['ignore', 'pipe', 'pipe'],
|
|
76
123
|
});
|
|
77
124
|
let stdout = '';
|
|
@@ -111,6 +158,14 @@ export async function bridgeCommand(input, args) {
|
|
|
111
158
|
const command = ['node', path.relative(input.context.projectRoot, script), ...redactBridgeArgs(args)].join(' ');
|
|
112
159
|
throw new Error(`Mobile CDP bridge command failed: ${command}\n${redactBridgeOutput(result.stderr || result.stdout, sensitiveBridgeArgs(args))}`);
|
|
113
160
|
}
|
|
161
|
+
// For transient commands, stdout of '' or 'undefined' means the state is not yet
|
|
162
|
+
// settled (e.g. mid-navigation). Return null so callers like waitForRoute can poll
|
|
163
|
+
// rather than aborting on a JSON parse error.
|
|
164
|
+
const command = String(args[0] ?? '');
|
|
165
|
+
const trimmedStdout = result.stdout.trim();
|
|
166
|
+
if ((trimmedStdout === '' || trimmedStdout === 'undefined') && TRANSIENT_NULL_COMMANDS.has(command)) {
|
|
167
|
+
return null;
|
|
168
|
+
}
|
|
114
169
|
try {
|
|
115
170
|
const parsed = JSON.parse(result.stdout);
|
|
116
171
|
if (parsed && typeof parsed === 'object' && !Array.isArray(parsed) && parsed.ok === false) {
|
|
@@ -195,13 +250,35 @@ function routeName(route) {
|
|
|
195
250
|
export async function waitForRoute(input, expectedRoute, timeoutMs = 15000) {
|
|
196
251
|
const expected = String(expectedRoute);
|
|
197
252
|
const deadline = Date.now() + timeoutMs;
|
|
253
|
+
// lastRoute is null when bridgeCommand returns null (transient: route not yet
|
|
254
|
+
// settled mid-navigation). Null means "not ready yet" — keep polling.
|
|
198
255
|
let lastRoute = null;
|
|
256
|
+
let pollCount = 0;
|
|
199
257
|
while (Date.now() < deadline) {
|
|
200
258
|
lastRoute = await bridgeCommand(input, ['get-route']);
|
|
201
259
|
if (routeName(lastRoute) === expected) return lastRoute;
|
|
260
|
+
pollCount += 1;
|
|
202
261
|
await sleep(250);
|
|
203
262
|
}
|
|
204
|
-
|
|
263
|
+
const target = resolveMobileTarget(input);
|
|
264
|
+
const deviceHint = [
|
|
265
|
+
target.adbSerial && `ADB_SERIAL=${target.adbSerial}`,
|
|
266
|
+
target.androidDevice && target.androidDevice !== target.adbSerial && `ANDROID_DEVICE=${target.androidDevice}`,
|
|
267
|
+
target.iosSimulator && `IOS_SIMULATOR=${target.iosSimulator}`,
|
|
268
|
+
].filter(Boolean).join(', ') || 'no device pin';
|
|
269
|
+
// bridgeCommand already parsed (or null-normalized) the reply, so raw stdout is
|
|
270
|
+
// not available here — describe the reply honestly instead of relabeling the
|
|
271
|
+
// parsed value as "raw".
|
|
272
|
+
const lastReply = lastRoute === null
|
|
273
|
+
? 'empty/undefined (route transiently unavailable — bridge not yet settled)'
|
|
274
|
+
: JSON.stringify(lastRoute);
|
|
275
|
+
throw new Error(
|
|
276
|
+
`Timed out waiting for Mobile route '${expected}' after ${timeoutMs}ms (${pollCount} polls).\n` +
|
|
277
|
+
` Expected route: ${expected}\n` +
|
|
278
|
+
` Last parsed route: ${JSON.stringify(lastRoute)}\n` +
|
|
279
|
+
` Last bridge reply: ${lastReply}\n` +
|
|
280
|
+
` Device: ${deviceHint}`,
|
|
281
|
+
);
|
|
205
282
|
}
|
|
206
283
|
|
|
207
284
|
export async function simulatorScreenshot(input, relPath) {
|
|
@@ -112,7 +112,10 @@ function setupWalletScript() {
|
|
|
112
112
|
return fileURLToPath(new URL('../../../../adapters/mobile/bridge-runtime/setup-wallet.sh', import.meta.url));
|
|
113
113
|
}
|
|
114
114
|
|
|
115
|
-
function runSetupWallet(input, fixture) {
|
|
115
|
+
async function runSetupWallet(input, fixture) {
|
|
116
|
+
// bridgeEnv is async (it may shell adb to resolve the Metro device name);
|
|
117
|
+
// spreading its un-awaited Promise would hand the child an almost-empty env.
|
|
118
|
+
const env = await bridgeEnv(input);
|
|
116
119
|
return new Promise((resolve, reject) => {
|
|
117
120
|
const timeoutMs = Number(
|
|
118
121
|
input.node?.setup_timeout_ms ?? input.node?.timeout_ms ?? 120000,
|
|
@@ -122,7 +125,7 @@ function runSetupWallet(input, fixture) {
|
|
|
122
125
|
[setupWalletScript(), '--fixture', fixture.absolutePath],
|
|
123
126
|
{
|
|
124
127
|
cwd: input.context.projectRoot,
|
|
125
|
-
env: { ...
|
|
128
|
+
env: { ...env, CDP_TIMEOUT: String(timeoutMs), APP_ROOT: input.context.projectRoot },
|
|
126
129
|
stdio: ['ignore', 'pipe', 'pipe'],
|
|
127
130
|
},
|
|
128
131
|
);
|