@deeeed/metamask-harness 0.9.0 → 0.10.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 +19 -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 +81 -11
- package/adapters/mobile/start-metro.sh +23 -6
- package/dist/adapters/extension/runtime.js +2 -3
- package/dist/commands/device-target.js +4 -0
- package/dist/commands/run-engine.js +39 -12
- package/dist/commands/run.js +1 -2
- package/dist/live-adapter-contract.js +2 -3
- package/docs/CLI-SPEC.md +3 -1
- package/docs/recipe-libraries.md +184 -0
- package/library/actions/mobile/platform/bridge.mjs +149 -7
- package/library/actions/mobile/wallet/ensure_unlocked.mjs +3 -24
- package/library/actions/mobile/wallet/read_state.mjs +3 -24
- package/library/actions/mobile/wallet/setup.mjs +14 -29
- package/library/manifests/extension.action-manifest.json +13 -0
- package/library/manifests/mobile.action-manifest.json +13 -0
- package/library/recipes/perps-performance.mobile.recipe.json +50 -0
- package/package.json +1 -1
package/CHANGELOG.md
CHANGED
|
@@ -1,5 +1,24 @@
|
|
|
1
1
|
# Changelog
|
|
2
2
|
|
|
3
|
+
## 0.10.0 - 2026-07-07
|
|
4
|
+
|
|
5
|
+
### Added
|
|
6
|
+
- **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.
|
|
7
|
+
- **`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`.
|
|
8
|
+
- **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.
|
|
9
|
+
- **`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.
|
|
10
|
+
|
|
11
|
+
### Fixed
|
|
12
|
+
- **A `--device` pin wins target selection end-to-end on dual-platform slots.** Three independently sufficient holes let a pinned android run drive the iOS simulator (observed live): the discovery simulator filter ran before the android pin and the slot's ambient `IOS_SIMULATOR` captured the candidate set; pins were only enforced when more than one candidate existed (a single WRONG candidate was silently accepted); and the wallet actions' status-entry selectors checked the ambient simulator identity first while never matching serials against Metro device names. Fixed at every layer with live-repro contract cases; an unmatchable pin fails fast listing the Metro candidates.
|
|
13
|
+
|
|
14
|
+
## 0.9.1 - 2026-07-07
|
|
15
|
+
|
|
16
|
+
### Fixed
|
|
17
|
+
- **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.
|
|
18
|
+
- **`--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.
|
|
19
|
+
- **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.
|
|
20
|
+
- **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.
|
|
21
|
+
|
|
3
22
|
## 0.9.0 - 2026-07-07
|
|
4
23
|
|
|
5
24
|
### 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);
|
|
@@ -114,9 +114,24 @@ async function discoverTarget(port) {
|
|
|
114
114
|
/bridgeless|hermes/i.test(t.description || '')),
|
|
115
115
|
);
|
|
116
116
|
|
|
117
|
-
//
|
|
117
|
+
// Android pin identities (loaded before the simulator filter so an explicit
|
|
118
|
+
// android pin can take precedence over an ambient simulator name):
|
|
119
|
+
// ANDROID_TARGET_DEVICE_NAME — Metro-compatible model prefix resolved by bridge.mjs
|
|
120
|
+
// from the adb serial via `adb -s <serial> shell getprop ro.product.model`.
|
|
121
|
+
// Example: "Pixel 6" matches Metro deviceName "Pixel 6 - 16 - API 36".
|
|
122
|
+
// ANDROID_DEVICE — exact Metro deviceName (backward compat / user-specified).
|
|
123
|
+
const androidTargetName = loadAndroidTargetDeviceName();
|
|
124
|
+
const androidDevice = loadAndroidDevice();
|
|
125
|
+
const adbSerial = process.env.ADB_SERIAL || process.env.ANDROID_SERIAL || '';
|
|
126
|
+
const androidPinned = Boolean(androidTargetName || androidDevice);
|
|
127
|
+
|
|
128
|
+
// Filter by simulator name if IOS_SIMULATOR is set — but never when an android
|
|
129
|
+
// pin is present: dual-platform slots carry an ambient IOS_SIMULATOR in their
|
|
130
|
+
// context, and letting it win here sends a pinned android action to the iOS
|
|
131
|
+
// target. No-match keeps the full candidate set (ambient sim configs tolerate a
|
|
132
|
+
// sim that is not currently attached).
|
|
118
133
|
const simName = loadSimulatorName();
|
|
119
|
-
if (simName && candidates.length > 1) {
|
|
134
|
+
if (simName && !androidPinned && candidates.length > 1) {
|
|
120
135
|
const deviceFiltered = candidates.filter(
|
|
121
136
|
(t) => t.deviceName === simName,
|
|
122
137
|
);
|
|
@@ -125,14 +140,69 @@ async function discoverTarget(port) {
|
|
|
125
140
|
}
|
|
126
141
|
}
|
|
127
142
|
|
|
128
|
-
//
|
|
129
|
-
|
|
130
|
-
|
|
131
|
-
|
|
132
|
-
|
|
133
|
-
|
|
134
|
-
|
|
135
|
-
|
|
143
|
+
// Android pin enforcement — at ANY candidate count. A pin gated on
|
|
144
|
+
// candidates.length > 1 silently accepted a single WRONG candidate (observed
|
|
145
|
+
// live: the pinned Pixel's target dropped off Metro while the iOS target
|
|
146
|
+
// remained; the pin was ignored and the recipe drove the simulator). When a
|
|
147
|
+
// device is pinned, we MUST NOT silently fall back to another target; an
|
|
148
|
+
// unmatchable pin fails fast with diagnostics.
|
|
149
|
+
if (androidPinned && candidates.length > 0) {
|
|
150
|
+
let androidFiltered = [];
|
|
151
|
+
|
|
152
|
+
if (androidTargetName) {
|
|
153
|
+
// Model prefix match: "Pixel 6" matches "Pixel 6 - 16 - API 36". Metro's
|
|
154
|
+
// /json/list has no explicit platform field, so scope the ANDROID pin by
|
|
155
|
+
// excluding the one iOS identity we do know (the pinned simulator name) —
|
|
156
|
+
// a simulator named to shadow a model string must not satisfy an android pin.
|
|
157
|
+
androidFiltered = candidates.filter(
|
|
158
|
+
(t) => t.deviceName !== simName &&
|
|
159
|
+
(t.deviceName === androidTargetName ||
|
|
160
|
+
(t.deviceName != null && t.deviceName.startsWith(androidTargetName))),
|
|
161
|
+
);
|
|
162
|
+
if (androidFiltered.length > 1) {
|
|
163
|
+
// Two same-model devices produce identical Metro model prefixes — the pin
|
|
164
|
+
// is genuinely ambiguous; picking one silently is exactly the bug this
|
|
165
|
+
// path exists to prevent.
|
|
166
|
+
const ambiguousList = androidFiltered
|
|
167
|
+
.map((t) => ` deviceName=${JSON.stringify(t.deviceName || '')} ws=${t.webSocketDebuggerUrl || ''}`)
|
|
168
|
+
.join('\n');
|
|
169
|
+
throw new Error(
|
|
170
|
+
`Pinned Android device is ambiguous: model '${androidTargetName}' matches ${androidFiltered.length} Metro targets.\n` +
|
|
171
|
+
` Requested --device (ADB_SERIAL): ${adbSerial || '(not set)'}\n` +
|
|
172
|
+
` Matching Metro targets:\n${ambiguousList}\n` +
|
|
173
|
+
` Set ANDROID_DEVICE to the exact Metro deviceName to disambiguate.`,
|
|
174
|
+
);
|
|
175
|
+
}
|
|
176
|
+
if (androidFiltered.length === 0) {
|
|
177
|
+
// Pinned device could not be matched — never silently pick another target.
|
|
178
|
+
const candidateList = targets
|
|
179
|
+
.map((t) => ` deviceName=${JSON.stringify(t.deviceName || '')} ws=${t.webSocketDebuggerUrl || ''}`)
|
|
180
|
+
.join('\n');
|
|
181
|
+
throw new Error(
|
|
182
|
+
`Pinned Android device did not match any Metro target.\n` +
|
|
183
|
+
` Requested --device (ADB_SERIAL): ${adbSerial || '(not set)'}\n` +
|
|
184
|
+
` Resolved model (ANDROID_TARGET_DEVICE_NAME): ${androidTargetName}\n` +
|
|
185
|
+
` ANDROID_DEVICE: ${androidDevice || '(not set)'}\n` +
|
|
186
|
+
` Metro /json/list candidates:\n${candidateList}`,
|
|
187
|
+
);
|
|
188
|
+
}
|
|
189
|
+
candidates = androidFiltered;
|
|
190
|
+
} else if (androidDevice) {
|
|
191
|
+
// Exact Metro deviceName match (user set ANDROID_DEVICE to the Metro name, or
|
|
192
|
+
// legacy path where ANDROID_DEVICE was not resolved from serial).
|
|
193
|
+
androidFiltered = candidates.filter((t) => t.deviceName === androidDevice);
|
|
194
|
+
if (androidFiltered.length === 0) {
|
|
195
|
+
// Pinned by exact Metro name but no candidate matched — fail fast.
|
|
196
|
+
const candidateList = targets
|
|
197
|
+
.map((t) => ` deviceName=${JSON.stringify(t.deviceName || '')} ws=${t.webSocketDebuggerUrl || ''}`)
|
|
198
|
+
.join('\n');
|
|
199
|
+
throw new Error(
|
|
200
|
+
`Pinned Android device (ANDROID_DEVICE='${androidDevice}') did not match any Metro target.\n` +
|
|
201
|
+
` ADB_SERIAL: ${adbSerial || '(not set)'}\n` +
|
|
202
|
+
` Metro /json/list candidates:\n${candidateList}`,
|
|
203
|
+
);
|
|
204
|
+
}
|
|
205
|
+
candidates = androidFiltered;
|
|
136
206
|
}
|
|
137
207
|
}
|
|
138
208
|
|
|
@@ -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
|
|
|
@@ -275,9 +275,8 @@ function runProcess(command, args, options) {
|
|
|
275
275
|
if (settled) return;
|
|
276
276
|
settled = true;
|
|
277
277
|
child.kill("SIGTERM");
|
|
278
|
-
setTimeout(() =>
|
|
279
|
-
|
|
280
|
-
}, 1e3);
|
|
278
|
+
const killTimer = setTimeout(() => child.kill("SIGKILL"), 1e3);
|
|
279
|
+
child.once("close", () => clearTimeout(killTimer));
|
|
281
280
|
resolve({ exitCode: null, stdout, stderr, timedOut: true });
|
|
282
281
|
}, options.timeoutMs) : void 0;
|
|
283
282
|
child.stdout.on("data", (chunk) => {
|
|
@@ -4,9 +4,13 @@ function setAndroidDeviceEnv(id) {
|
|
|
4
4
|
process.env.ADB_SERIAL = id;
|
|
5
5
|
process.env.ANDROID_SERIAL = id;
|
|
6
6
|
process.env.ANDROID_DEVICE = id;
|
|
7
|
+
process.env.IOS_SIMULATOR = "";
|
|
7
8
|
}
|
|
8
9
|
function setIosDeviceEnv(id) {
|
|
9
10
|
process.env.IOS_SIMULATOR = id;
|
|
11
|
+
process.env.ADB_SERIAL = "";
|
|
12
|
+
process.env.ANDROID_SERIAL = "";
|
|
13
|
+
process.env.ANDROID_DEVICE = "";
|
|
10
14
|
}
|
|
11
15
|
function formatConnectedDevices(devices) {
|
|
12
16
|
return devices.map((d) => ` - ${d.id}${d.name ? ` (${d.name})` : ""} [${d.state}] ${d.platform}`).join("\n");
|
|
@@ -139,19 +139,30 @@ function isRecipeFile(p) {
|
|
|
139
139
|
return false;
|
|
140
140
|
}
|
|
141
141
|
}
|
|
142
|
-
function resolveRunRecipeArg(recipeArg, adapter) {
|
|
142
|
+
function resolveRunRecipeArg(recipeArg, adapter, librarySources) {
|
|
143
143
|
const direct = path.resolve(recipeArg);
|
|
144
144
|
if (isRecipeFile(direct)) return { recipeFile: direct };
|
|
145
145
|
if (!recipeArg.includes("/") && !recipeArg.includes(path.sep)) {
|
|
146
|
-
|
|
147
|
-
|
|
148
|
-
|
|
146
|
+
const candidates = [`${recipeArg}.${adapter}.recipe.json`, `${recipeArg}.recipe.json`, recipeArg];
|
|
147
|
+
if (librarySources && librarySources.length > 0) {
|
|
148
|
+
for (const source of librarySources) {
|
|
149
|
+
for (const candidate of candidates) {
|
|
150
|
+
const file = path.join(source.root, "recipes", candidate);
|
|
151
|
+
if (isRecipeFile(file)) return { recipeFile: file };
|
|
152
|
+
}
|
|
153
|
+
}
|
|
154
|
+
} else {
|
|
155
|
+
for (const candidate of candidates) {
|
|
156
|
+
const file = recipePath(candidate);
|
|
157
|
+
if (isRecipeFile(file)) return { recipeFile: file };
|
|
158
|
+
}
|
|
149
159
|
}
|
|
150
160
|
}
|
|
151
|
-
const
|
|
152
|
-
|
|
153
|
-
|
|
154
|
-
}
|
|
161
|
+
const packagedNames = libraryRecipeNames(adapter);
|
|
162
|
+
const nonCanonical = librarySources?.filter((s) => s.name !== "metamask") ?? [];
|
|
163
|
+
const notFoundCore = nonCanonical.length > 0 ? `recipe not found: ${recipeArg} \u2014 not a file, and no recipe matched in library sources [${librarySources.map((s) => s.name ?? path.basename(s.root)).join(", ")}].` : `recipe not found: ${recipeArg} \u2014 not a file, and no packaged library recipe matched.`;
|
|
164
|
+
const suffix = packagedNames.length > 0 ? ` Library recipes for ${adapter}: ${packagedNames.join(", ")} (mm-harness run <name>).` : ` The packaged library has no recipes for ${adapter}.`;
|
|
165
|
+
return { notFound: notFoundCore + suffix };
|
|
155
166
|
}
|
|
156
167
|
function libraryRecipeNames(adapter) {
|
|
157
168
|
let entries;
|
|
@@ -168,9 +179,18 @@ function libraryRecipeNames(adapter) {
|
|
|
168
179
|
return [...new Set(names)].sort();
|
|
169
180
|
}
|
|
170
181
|
async function validateRunRecipeStatic(recipeArg, adapter, options) {
|
|
171
|
-
const
|
|
182
|
+
const librarySources = await resolveMetaMaskLibrarySources(optionString(options, "library"));
|
|
183
|
+
const resolved = resolveRunRecipeArg(recipeArg, adapter, librarySources);
|
|
172
184
|
const recipeFile = "recipeFile" in resolved ? resolved.recipeFile : path.resolve(recipeArg);
|
|
173
|
-
const empty = {
|
|
185
|
+
const empty = {
|
|
186
|
+
recipe: void 0,
|
|
187
|
+
recipeFile,
|
|
188
|
+
findings: [],
|
|
189
|
+
errorCount: 0,
|
|
190
|
+
manifestOk: false,
|
|
191
|
+
schemaValid: false,
|
|
192
|
+
...librarySources ? { librarySources } : {}
|
|
193
|
+
};
|
|
174
194
|
if ("notFound" in resolved) {
|
|
175
195
|
return { ...empty, usageError: { code: "RECIPE_NOT_FOUND", message: resolved.notFound } };
|
|
176
196
|
}
|
|
@@ -200,11 +220,18 @@ async function validateRunRecipeStatic(recipeArg, adapter, options) {
|
|
|
200
220
|
message: error instanceof Error ? error.message : String(error)
|
|
201
221
|
});
|
|
202
222
|
}
|
|
203
|
-
const librarySources = await resolveMetaMaskLibrarySources(optionString(options, "library"));
|
|
204
223
|
const validation = manifestOk ? await validateRecipeAdapterAware(recipe, manifest, librarySources) : { status: "invalid", findings: [], summary: { errors: 1, warnings: 0 } };
|
|
205
224
|
findings.push(...validation.findings);
|
|
206
225
|
const errorCount = findings.filter((finding) => finding.severity === "error").length;
|
|
207
|
-
return {
|
|
226
|
+
return {
|
|
227
|
+
recipe,
|
|
228
|
+
recipeFile,
|
|
229
|
+
findings,
|
|
230
|
+
errorCount,
|
|
231
|
+
manifestOk,
|
|
232
|
+
schemaValid: validation.status === "valid",
|
|
233
|
+
...librarySources ? { librarySources } : {}
|
|
234
|
+
};
|
|
208
235
|
}
|
|
209
236
|
async function resolveMetaMaskLibrarySources(libraryEntry) {
|
|
210
237
|
const harness = await importRecipeHarness();
|
package/dist/commands/run.js
CHANGED
|
@@ -15,7 +15,6 @@ import {
|
|
|
15
15
|
emitHealViolation,
|
|
16
16
|
executeWithHealBounds,
|
|
17
17
|
prepareHeal,
|
|
18
|
-
resolveMetaMaskLibrarySources,
|
|
19
18
|
runRecipe,
|
|
20
19
|
validateRunRecipeStatic
|
|
21
20
|
} from "./run-engine.js";
|
|
@@ -43,7 +42,7 @@ async function handleRun({ positional, options }) {
|
|
|
43
42
|
if (validated.errorCount > 0) {
|
|
44
43
|
return emitRunValidationError(json, adapter, validated.recipeFile, validated.findings, validated.errorCount);
|
|
45
44
|
}
|
|
46
|
-
const librarySources =
|
|
45
|
+
const librarySources = validated.librarySources;
|
|
47
46
|
const runtimeOptions = {
|
|
48
47
|
...runtimeOptionsFromCli(options),
|
|
49
48
|
...librarySources ? { librarySources } : {},
|
|
@@ -100,9 +100,8 @@ function runProcess(command, args, options) {
|
|
|
100
100
|
if (settled) return;
|
|
101
101
|
settled = true;
|
|
102
102
|
child.kill("SIGTERM");
|
|
103
|
-
setTimeout(() =>
|
|
104
|
-
|
|
105
|
-
}, 1e3);
|
|
103
|
+
const killTimer = setTimeout(() => child.kill("SIGKILL"), 1e3);
|
|
104
|
+
child.once("close", () => clearTimeout(killTimer));
|
|
106
105
|
resolve({ exitCode: null, stdout, stderr, timedOut: true });
|
|
107
106
|
}, options.timeoutMs) : void 0;
|
|
108
107
|
child.on("error", (error) => {
|
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
|
|---|---|---|
|
package/docs/recipe-libraries.md
CHANGED
|
@@ -93,3 +93,187 @@ and a small flow budget. If a flow is team- or task-specific, it belongs in a
|
|
|
93
93
|
team or personal library — that is what the precedence order is for.
|
|
94
94
|
`scripts/check.mjs` validates every committed catalog against the action
|
|
95
95
|
manifests.
|
|
96
|
+
|
|
97
|
+
## Your own measured flow: creating a personal recipe library
|
|
98
|
+
|
|
99
|
+
A **measured flow** is a recipe you run the same way every time to watch how long
|
|
100
|
+
each user-visible step takes. The runner has no benchmark verb: timings are just
|
|
101
|
+
the per-node `duration`s in a passing run's `trace.json`, so a flow is comparable
|
|
102
|
+
across runs only when you pin the run (same device, healing off) and keep the node
|
|
103
|
+
graph stable. The repo ships one canonical example,
|
|
104
|
+
`library/recipes/perps-performance.mobile.recipe.json` — unlock → open the Perps
|
|
105
|
+
market list → read live state → open a market detail. This walkthrough copies it
|
|
106
|
+
into a library of your own and retargets it to your journey. A peer engineer can
|
|
107
|
+
follow it verbatim; the same steps run as the `perps-performance-recipe` contract
|
|
108
|
+
test.
|
|
109
|
+
|
|
110
|
+
### 1. Scaffold a personal library
|
|
111
|
+
|
|
112
|
+
A library is a directory with a `library.json` marker. Keep reusable `flows/`
|
|
113
|
+
(referenced via `call`) beside a `recipes/` folder for the full flows you run:
|
|
114
|
+
|
|
115
|
+
```bash
|
|
116
|
+
mkdir -p ~/my-recipes/flows ~/my-recipes/recipes
|
|
117
|
+
cat > ~/my-recipes/library.json <<'JSON'
|
|
118
|
+
{ "kind": "recipe-library", "schema_version": 1, "name": "mydev", "owner": "mydev" }
|
|
119
|
+
JSON
|
|
120
|
+
```
|
|
121
|
+
|
|
122
|
+
In real use this is discovered for you: with no `--library` flag and no
|
|
123
|
+
`RECIPE_LIBRARY_PATH`, the runner reads your personal library at
|
|
124
|
+
`$FARMSLOT_HOME/recipe-library` (default `~/.farmslot/recipe-library`). The
|
|
125
|
+
explicit `--library mydev=<dir>` form below is the same mechanism, spelled out so
|
|
126
|
+
it works headlessly (CI, a scratch checkout) and so the path is unambiguous.
|
|
127
|
+
|
|
128
|
+
### 2. Copy the canonical recipe as a starting point
|
|
129
|
+
|
|
130
|
+
Copy it out of your runner checkout's `library/recipes/`:
|
|
131
|
+
|
|
132
|
+
```bash
|
|
133
|
+
cp library/recipes/perps-performance.mobile.recipe.json \
|
|
134
|
+
~/my-recipes/recipes/my-perps-performance.mobile.recipe.json
|
|
135
|
+
```
|
|
136
|
+
|
|
137
|
+
### 3. Edit the nodes to your journey
|
|
138
|
+
|
|
139
|
+
Open the copy and change what you measure while keeping the measured-flow shape.
|
|
140
|
+
Retarget `open-market-detail` to your market and add one extra measured step —
|
|
141
|
+
here, reading live orders on the detail screen:
|
|
142
|
+
|
|
143
|
+
```jsonc
|
|
144
|
+
"open-market-detail": {
|
|
145
|
+
"action": "ui.navigate",
|
|
146
|
+
"page": "perps-market",
|
|
147
|
+
"market": "ETH", // was BTC
|
|
148
|
+
"intent": "Open the ETH Perps market detail screen",
|
|
149
|
+
"next": "read-orders" // was "end"
|
|
150
|
+
},
|
|
151
|
+
"read-orders": { // your extra measured step
|
|
152
|
+
"action": "metamask.perps.read_orders",
|
|
153
|
+
"market": "ETH",
|
|
154
|
+
"intent": "Read live ETH orders on the market detail screen",
|
|
155
|
+
"next": "end"
|
|
156
|
+
}
|
|
157
|
+
```
|
|
158
|
+
|
|
159
|
+
The canonical recipe keeps its nodes inline — a clean measured baseline. For
|
|
160
|
+
bigger journeys you can extract repeated setup steps into a personal `flows/`
|
|
161
|
+
segment and `call` it. For example, define an unlock + open-Perps-list segment
|
|
162
|
+
in `~/my-recipes/flows/mydev.flows.json`:
|
|
163
|
+
|
|
164
|
+
```json
|
|
165
|
+
{
|
|
166
|
+
"schema_version": 1, "kind": "recipe-flow-catalog", "owner": "mydev",
|
|
167
|
+
"flows": {
|
|
168
|
+
"mydev.open_perps_setup": {
|
|
169
|
+
"version": 1,
|
|
170
|
+
"description": "Unlock the wallet and open the Perps market list.",
|
|
171
|
+
"workflow": {
|
|
172
|
+
"entry": "ensure-unlocked",
|
|
173
|
+
"nodes": {
|
|
174
|
+
"ensure-unlocked": {
|
|
175
|
+
"action": "metamask.wallet.ensure_unlocked",
|
|
176
|
+
"intent": "Unlock the wallet before the Perps journey",
|
|
177
|
+
"next": "open-perps-list"
|
|
178
|
+
},
|
|
179
|
+
"open-perps-list": {
|
|
180
|
+
"action": "ui.navigate",
|
|
181
|
+
"page": "perps",
|
|
182
|
+
"intent": "Open the Perps market list screen",
|
|
183
|
+
"next": "done"
|
|
184
|
+
},
|
|
185
|
+
"done": { "action": "end", "status": "pass" }
|
|
186
|
+
}
|
|
187
|
+
}
|
|
188
|
+
}
|
|
189
|
+
}
|
|
190
|
+
}
|
|
191
|
+
```
|
|
192
|
+
|
|
193
|
+
Then replace the two inline setup nodes in your recipe with a single `call` node:
|
|
194
|
+
|
|
195
|
+
```jsonc
|
|
196
|
+
"setup": {
|
|
197
|
+
"action": "call",
|
|
198
|
+
"ref": "mydev.open_perps_setup",
|
|
199
|
+
"intent": "Run the personal Perps setup segment (unlock + open list)",
|
|
200
|
+
"next": "read-positions"
|
|
201
|
+
}
|
|
202
|
+
```
|
|
203
|
+
|
|
204
|
+
The segment validates via `--plan` — the plan step verifies the `call` ref
|
|
205
|
+
resolves from the library. Keep flow segment actions within the mobile action
|
|
206
|
+
surface: the flow catalog format has no `platform`/`adapter` dimension today,
|
|
207
|
+
so a flow using a core-only action (e.g. `command`) will pass `--plan` on mobile
|
|
208
|
+
but fail at live-run time with "No adapter registered for flow action X". There
|
|
209
|
+
is no plan-time cross-adapter enforcement; that gap would require an `adapters`
|
|
210
|
+
field on flow catalog entries — not yet in the protocol.
|
|
211
|
+
|
|
212
|
+
### 4. Run it and read the timings
|
|
213
|
+
|
|
214
|
+
Validate statically **by name** first — `run` probes each library source's `recipes/`
|
|
215
|
+
directory in precedence order (personal → team → canonical), so `my-perps-performance`
|
|
216
|
+
resolves from `~/my-recipes/recipes/` without you spelling out the path:
|
|
217
|
+
|
|
218
|
+
```bash
|
|
219
|
+
# Static validation by NAME — resolves from the personal library via --library.
|
|
220
|
+
mm-harness run my-perps-performance \
|
|
221
|
+
--library mydev=~/my-recipes --plan --adapter mobile
|
|
222
|
+
|
|
223
|
+
# Zero-flag personal-library: when ~/my-recipes is placed at
|
|
224
|
+
# $FARMSLOT_HOME/recipe-library (default ~/.farmslot/recipe-library), the runner
|
|
225
|
+
# discovers it automatically and run-by-name works without --library:
|
|
226
|
+
mm-harness run my-perps-performance --plan --adapter mobile
|
|
227
|
+
|
|
228
|
+
# Pinned live run — same device, healing OFF, so durations are comparable.
|
|
229
|
+
mm-harness run my-perps-performance \
|
|
230
|
+
--library mydev=~/my-recipes \
|
|
231
|
+
--adapter mobile --device <serial> --heal off --artifacts-dir artifacts
|
|
232
|
+
|
|
233
|
+
# Per-node durations to diff across runs (trace.json is an array of entries, or
|
|
234
|
+
# { metadata, entries: [...] }; each entry carries nodeId + durationMs):
|
|
235
|
+
node -e 'const t=require("./artifacts/trace.json"); \
|
|
236
|
+
for (const e of Array.isArray(t)?t:t.entries) console.log(e.nodeId, e.durationMs)'
|
|
237
|
+
```
|
|
238
|
+
|
|
239
|
+
A miss with `--library` names the sources that were searched, so you can tell at a
|
|
240
|
+
glance whether a typo or a missing library entry caused the failure.
|
|
241
|
+
|
|
242
|
+
### The measured-flow pattern
|
|
243
|
+
|
|
244
|
+
Five rules keep timings meaningful and diffable:
|
|
245
|
+
|
|
246
|
+
1. **Pin the run** — `--device <serial> --heal off`. Healing retries hide the
|
|
247
|
+
regressions you are trying to measure.
|
|
248
|
+
2. **One node per user-visible step** — a node's `duration` is only a signal when
|
|
249
|
+
it maps to a single thing the user sees.
|
|
250
|
+
3. **Stable, human-meaningful node keys** — operators diff node keys across runs;
|
|
251
|
+
renaming `open-market-detail` breaks every historical comparison.
|
|
252
|
+
4. **No destructive side effects** — a measured flow should be repeatable. The
|
|
253
|
+
canonical recipe stops at read + navigate; add `place_order`/`close` only in a
|
|
254
|
+
personal copy when you deliberately want to measure the trade path.
|
|
255
|
+
5. **Read timings from `trace.json`, not the console** — the trace is the durable
|
|
256
|
+
per-node record; the console is for humans watching the run.
|
|
257
|
+
|
|
258
|
+
### Shadowing, in practice
|
|
259
|
+
|
|
260
|
+
Because `--library` sources resolve `call` refs before the canonical `metamask`
|
|
261
|
+
library, a personal flow named like a canonical one shadows it — your history is
|
|
262
|
+
the point. You can watch this happen on mobile with the segment recipe from the
|
|
263
|
+
walkthrough above:
|
|
264
|
+
|
|
265
|
+
```bash
|
|
266
|
+
# A mobile recipe with "action": "call", "ref": "mydev.open_perps_setup"
|
|
267
|
+
# fails without the library source…
|
|
268
|
+
mm-harness run my-perps-with-segment --plan --adapter mobile
|
|
269
|
+
# → workflow.unresolved_call_ref
|
|
270
|
+
|
|
271
|
+
# …and resolves once the personal library is on the path:
|
|
272
|
+
mm-harness run my-perps-with-segment --plan --adapter mobile \
|
|
273
|
+
--library "mydev=~/my-recipes"
|
|
274
|
+
# → plan pass
|
|
275
|
+
```
|
|
276
|
+
|
|
277
|
+
That resolution — unresolved without the source, `pass` with it — is what the
|
|
278
|
+
`perps-performance-recipe` contract test asserts, alongside the canonical recipe
|
|
279
|
+
resolving by name and the personal mobile copy planning by path and basename.
|
|
@@ -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,32 @@ 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
|
+
// ANDROID_DEVICE === ADB_SERIAL is the explicit --device android pin shape
|
|
96
|
+
// (device-target.ts / launch --device). Slots hosting BOTH platforms carry an
|
|
97
|
+
// ambient IOS_SIMULATOR in their context env, which spreads over process.env
|
|
98
|
+
// above and would win target discovery's simulator filter — sending a pinned
|
|
99
|
+
// android action to the iOS target. An explicit android pin suppresses the
|
|
100
|
+
// ambient simulator identity.
|
|
101
|
+
// LOAD-BEARING: device-target.ts also clears IOS_SIMULATOR, but
|
|
102
|
+
// resolveSlotPorts re-injects the slot context over process.env afterwards —
|
|
103
|
+
// this re-clear (running last, on the child env actually handed to the
|
|
104
|
+
// bridge) is what actually protects the pin. Keep it even if the CLI-level
|
|
105
|
+
// clear looks redundant.
|
|
106
|
+
env.IOS_SIMULATOR = '';
|
|
107
|
+
}
|
|
56
108
|
return env;
|
|
57
109
|
}
|
|
58
110
|
|
|
@@ -65,13 +117,70 @@ function resolveMobileTarget(input) {
|
|
|
65
117
|
return { watcherPort, iosSimulator, androidDevice, adbSerial };
|
|
66
118
|
}
|
|
67
119
|
|
|
120
|
+
// Select THIS action's entry from a multi-target bridge `status` reply (the bridge
|
|
121
|
+
// probes every RN target on Metro, so dual-device slots return an array). Single
|
|
122
|
+
// source of truth for wallet actions — the previous per-action copies checked the
|
|
123
|
+
// ambient IOS_SIMULATOR before the android pin and only ever exact-matched serials
|
|
124
|
+
// against Metro deviceNames (which are model descriptors, never serials), so a
|
|
125
|
+
// pinned android run selected the iOS entry on dual-platform slots.
|
|
126
|
+
export function selectBridgeStatusEntry(status, input) {
|
|
127
|
+
if (!Array.isArray(status)) {
|
|
128
|
+
return status && typeof status === 'object' ? status : null;
|
|
129
|
+
}
|
|
130
|
+
const entries = status.filter((entry) => entry && typeof entry === 'object');
|
|
131
|
+
if (entries.length === 0) return null;
|
|
132
|
+
const target = resolveMobileTarget(input);
|
|
133
|
+
const adbSerial = target.adbSerial != null ? String(target.adbSerial) : '';
|
|
134
|
+
const androidDevice = target.androidDevice != null ? String(target.androidDevice) : '';
|
|
135
|
+
const iosSimulator = target.iosSimulator != null ? String(target.iosSimulator) : '';
|
|
136
|
+
|
|
137
|
+
// Explicit android pin shape (--device <serial> sets ANDROID_DEVICE === ADB_SERIAL).
|
|
138
|
+
// Checked BEFORE the simulator identity: dual-platform slots inject an ambient
|
|
139
|
+
// IOS_SIMULATOR that must not capture a pinned android action.
|
|
140
|
+
if (adbSerial && androidDevice === adbSerial) {
|
|
141
|
+
const android = entries.filter((entry) => entry.platform === 'android');
|
|
142
|
+
if (android.length === 1) return android[0];
|
|
143
|
+
if (android.length > 1) {
|
|
144
|
+
// Two android targets can't be told apart by serial here (Metro deviceNames
|
|
145
|
+
// are model descriptors); prefer the usable one.
|
|
146
|
+
return android.find((entry) => entry.account) ?? android[0];
|
|
147
|
+
}
|
|
148
|
+
// Pinned android but no android entry answered — failing fast beats letting the
|
|
149
|
+
// caller read the iOS entry and report misleading wallet state.
|
|
150
|
+
throw new Error(
|
|
151
|
+
`Pinned android device ${adbSerial} has no responding bridge target.\n` +
|
|
152
|
+
` Bridge status entries:\n` +
|
|
153
|
+
entries.map((entry) => ` - ${entry.deviceName ?? '?'} [${entry.platform || 'unknown'}]`).join('\n') +
|
|
154
|
+
`\n Next: mm-harness launch android # bring the app back to the foreground`,
|
|
155
|
+
);
|
|
156
|
+
}
|
|
157
|
+
if (iosSimulator) {
|
|
158
|
+
const sim = entries.find((entry) => entry.deviceName === iosSimulator);
|
|
159
|
+
if (sim) return sim;
|
|
160
|
+
}
|
|
161
|
+
if (androidDevice) {
|
|
162
|
+
const byName = entries.find(
|
|
163
|
+
(entry) => entry.deviceName === androidDevice || String(entry.deviceName ?? '').startsWith(androidDevice),
|
|
164
|
+
);
|
|
165
|
+
if (byName) return byName;
|
|
166
|
+
}
|
|
167
|
+
return entries.find((entry) => entry.account) ?? entries[0];
|
|
168
|
+
}
|
|
169
|
+
|
|
170
|
+
// Commands where a transient undefined/empty stdout means "not yet settled", not failure.
|
|
171
|
+
// get-route returns undefined mid-navigation when the route state is momentarily unavailable;
|
|
172
|
+
// waitForRoute polls through these nulls rather than aborting on a parse error.
|
|
173
|
+
const TRANSIENT_NULL_COMMANDS = new Set(['get-route']);
|
|
174
|
+
|
|
68
175
|
export async function bridgeCommand(input, args) {
|
|
69
176
|
const script = bridgeScript(input);
|
|
177
|
+
// bridgeEnv is async: it may call `adb getprop` to resolve the Metro device name.
|
|
178
|
+
const env = await bridgeEnv(input);
|
|
70
179
|
const result = await new Promise((resolve, reject) => {
|
|
71
180
|
const timeoutMs = Number(input.node?.bridge_timeout_ms ?? input.node?.cdp_timeout_ms ?? process.env.CDP_TIMEOUT ?? 30000);
|
|
72
181
|
const child = spawn(process.execPath, [script, ...args], {
|
|
73
182
|
cwd: input.context.projectRoot,
|
|
74
|
-
env: { ...
|
|
183
|
+
env: { ...env, APP_ROOT: input.context.projectRoot },
|
|
75
184
|
stdio: ['ignore', 'pipe', 'pipe'],
|
|
76
185
|
});
|
|
77
186
|
let stdout = '';
|
|
@@ -82,9 +191,12 @@ export async function bridgeCommand(input, args) {
|
|
|
82
191
|
if (settled) return;
|
|
83
192
|
settled = true;
|
|
84
193
|
child.kill('SIGTERM');
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
|
|
194
|
+
// Always escalate after the grace period: child.killed only records that
|
|
195
|
+
// kill() was CALLED, not that the process exited, so gating SIGKILL on it
|
|
196
|
+
// never fires and a SIGTERM-ignoring child leaks. The close listener
|
|
197
|
+
// cancels the escalation when the child exits in time.
|
|
198
|
+
const killTimer = setTimeout(() => child.kill('SIGKILL'), 1000);
|
|
199
|
+
child.once('close', () => clearTimeout(killTimer));
|
|
88
200
|
resolve({ exitCode: null, stdout, stderr, timedOut: true, timeoutMs });
|
|
89
201
|
}, timeoutMs)
|
|
90
202
|
: null;
|
|
@@ -111,6 +223,14 @@ export async function bridgeCommand(input, args) {
|
|
|
111
223
|
const command = ['node', path.relative(input.context.projectRoot, script), ...redactBridgeArgs(args)].join(' ');
|
|
112
224
|
throw new Error(`Mobile CDP bridge command failed: ${command}\n${redactBridgeOutput(result.stderr || result.stdout, sensitiveBridgeArgs(args))}`);
|
|
113
225
|
}
|
|
226
|
+
// For transient commands, stdout of '' or 'undefined' means the state is not yet
|
|
227
|
+
// settled (e.g. mid-navigation). Return null so callers like waitForRoute can poll
|
|
228
|
+
// rather than aborting on a JSON parse error.
|
|
229
|
+
const command = String(args[0] ?? '');
|
|
230
|
+
const trimmedStdout = result.stdout.trim();
|
|
231
|
+
if ((trimmedStdout === '' || trimmedStdout === 'undefined') && TRANSIENT_NULL_COMMANDS.has(command)) {
|
|
232
|
+
return null;
|
|
233
|
+
}
|
|
114
234
|
try {
|
|
115
235
|
const parsed = JSON.parse(result.stdout);
|
|
116
236
|
if (parsed && typeof parsed === 'object' && !Array.isArray(parsed) && parsed.ok === false) {
|
|
@@ -195,13 +315,35 @@ function routeName(route) {
|
|
|
195
315
|
export async function waitForRoute(input, expectedRoute, timeoutMs = 15000) {
|
|
196
316
|
const expected = String(expectedRoute);
|
|
197
317
|
const deadline = Date.now() + timeoutMs;
|
|
318
|
+
// lastRoute is null when bridgeCommand returns null (transient: route not yet
|
|
319
|
+
// settled mid-navigation). Null means "not ready yet" — keep polling.
|
|
198
320
|
let lastRoute = null;
|
|
321
|
+
let pollCount = 0;
|
|
199
322
|
while (Date.now() < deadline) {
|
|
200
323
|
lastRoute = await bridgeCommand(input, ['get-route']);
|
|
201
324
|
if (routeName(lastRoute) === expected) return lastRoute;
|
|
325
|
+
pollCount += 1;
|
|
202
326
|
await sleep(250);
|
|
203
327
|
}
|
|
204
|
-
|
|
328
|
+
const target = resolveMobileTarget(input);
|
|
329
|
+
const deviceHint = [
|
|
330
|
+
target.adbSerial && `ADB_SERIAL=${target.adbSerial}`,
|
|
331
|
+
target.androidDevice && target.androidDevice !== target.adbSerial && `ANDROID_DEVICE=${target.androidDevice}`,
|
|
332
|
+
target.iosSimulator && `IOS_SIMULATOR=${target.iosSimulator}`,
|
|
333
|
+
].filter(Boolean).join(', ') || 'no device pin';
|
|
334
|
+
// bridgeCommand already parsed (or null-normalized) the reply, so raw stdout is
|
|
335
|
+
// not available here — describe the reply honestly instead of relabeling the
|
|
336
|
+
// parsed value as "raw".
|
|
337
|
+
const lastReply = lastRoute === null
|
|
338
|
+
? 'empty/undefined (route transiently unavailable — bridge not yet settled)'
|
|
339
|
+
: JSON.stringify(lastRoute);
|
|
340
|
+
throw new Error(
|
|
341
|
+
`Timed out waiting for Mobile route '${expected}' after ${timeoutMs}ms (${pollCount} polls).\n` +
|
|
342
|
+
` Expected route: ${expected}\n` +
|
|
343
|
+
` Last parsed route: ${JSON.stringify(lastRoute)}\n` +
|
|
344
|
+
` Last bridge reply: ${lastReply}\n` +
|
|
345
|
+
` Device: ${deviceHint}`,
|
|
346
|
+
);
|
|
205
347
|
}
|
|
206
348
|
|
|
207
349
|
export async function simulatorScreenshot(input, relPath) {
|
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
import { readFile } from 'node:fs/promises';
|
|
2
|
-
import { bridgeCommand, runAdapter } from '../platform/bridge.mjs';
|
|
2
|
+
import { bridgeCommand, runAdapter, selectBridgeStatusEntry } from '../platform/bridge.mjs';
|
|
3
3
|
import { walletFixturePath } from '../../harness-exports.mjs';
|
|
4
4
|
|
|
5
5
|
async function fixturePassword(projectRoot) {
|
|
@@ -34,29 +34,8 @@ function routeName(status, input) {
|
|
|
34
34
|
return route && typeof route === 'object' ? String(route.name ?? '') : '';
|
|
35
35
|
}
|
|
36
36
|
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
return status && typeof status === 'object' ? status : null;
|
|
40
|
-
}
|
|
41
|
-
const preferredDevices = [
|
|
42
|
-
input.node?.ios_simulator,
|
|
43
|
-
input.node?.simulator,
|
|
44
|
-
input.node?.android_device,
|
|
45
|
-
input.node?.adb_serial,
|
|
46
|
-
process.env.IOS_SIMULATOR,
|
|
47
|
-
process.env.ANDROID_DEVICE,
|
|
48
|
-
process.env.ADB_SERIAL,
|
|
49
|
-
].filter((value) => typeof value === 'string' && value.length > 0);
|
|
50
|
-
for (const preferredDevice of preferredDevices) {
|
|
51
|
-
const match = status.find((entry) => entry?.deviceName === preferredDevice);
|
|
52
|
-
if (match) return match;
|
|
53
|
-
}
|
|
54
|
-
return (
|
|
55
|
-
status.find((entry) => entry?.account) ??
|
|
56
|
-
status.find((entry) => entry && typeof entry === 'object') ??
|
|
57
|
-
null
|
|
58
|
-
);
|
|
59
|
-
}
|
|
37
|
+
// selectedStatus: shared pin-aware entry selection (see selectBridgeStatusEntry).
|
|
38
|
+
const selectedStatus = selectBridgeStatusEntry;
|
|
60
39
|
|
|
61
40
|
async function status(input) {
|
|
62
41
|
return bridgeCommand(input, ['status']);
|
|
@@ -1,28 +1,7 @@
|
|
|
1
|
-
import { bridgeCommand, runAdapter } from '../platform/bridge.mjs';
|
|
1
|
+
import { bridgeCommand, runAdapter, selectBridgeStatusEntry } from '../platform/bridge.mjs';
|
|
2
2
|
|
|
3
|
-
|
|
4
|
-
|
|
5
|
-
return status && typeof status === 'object' ? status : null;
|
|
6
|
-
}
|
|
7
|
-
const preferredDevices = [
|
|
8
|
-
input.node?.ios_simulator,
|
|
9
|
-
input.node?.simulator,
|
|
10
|
-
input.node?.android_device,
|
|
11
|
-
input.node?.adb_serial,
|
|
12
|
-
process.env.IOS_SIMULATOR,
|
|
13
|
-
process.env.ANDROID_DEVICE,
|
|
14
|
-
process.env.ADB_SERIAL,
|
|
15
|
-
].filter((value) => typeof value === 'string' && value.length > 0);
|
|
16
|
-
for (const preferredDevice of preferredDevices) {
|
|
17
|
-
const match = status.find((entry) => entry?.deviceName === preferredDevice);
|
|
18
|
-
if (match) return match;
|
|
19
|
-
}
|
|
20
|
-
return (
|
|
21
|
-
status.find((entry) => entry?.account) ??
|
|
22
|
-
status.find((entry) => entry && typeof entry === 'object') ??
|
|
23
|
-
null
|
|
24
|
-
);
|
|
25
|
-
}
|
|
3
|
+
// selectedStatus: shared pin-aware entry selection (see selectBridgeStatusEntry).
|
|
4
|
+
const selectedStatus = selectBridgeStatusEntry;
|
|
26
5
|
|
|
27
6
|
runAdapter(async (input) => {
|
|
28
7
|
const status = selectedStatus(await bridgeCommand(input, ['status']), input);
|
|
@@ -2,7 +2,7 @@ import { readFile } from 'node:fs/promises';
|
|
|
2
2
|
import { spawn } from 'node:child_process';
|
|
3
3
|
import path from 'node:path';
|
|
4
4
|
import { fileURLToPath } from 'node:url';
|
|
5
|
-
import { bridgeCommand, bridgeEnv, runAdapter } from '../platform/bridge.mjs';
|
|
5
|
+
import { bridgeCommand, bridgeEnv, runAdapter, selectBridgeStatusEntry } from '../platform/bridge.mjs';
|
|
6
6
|
import { walletFixturePath } from '../../harness-exports.mjs';
|
|
7
7
|
|
|
8
8
|
async function fixtureProfile(projectRoot) {
|
|
@@ -77,29 +77,8 @@ function mnemonicAccountCount(account, fixturePath, index) {
|
|
|
77
77
|
return count;
|
|
78
78
|
}
|
|
79
79
|
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
return status && typeof status === 'object' ? status : null;
|
|
83
|
-
}
|
|
84
|
-
const preferredDevices = [
|
|
85
|
-
input.node?.ios_simulator,
|
|
86
|
-
input.node?.simulator,
|
|
87
|
-
input.node?.android_device,
|
|
88
|
-
input.node?.adb_serial,
|
|
89
|
-
process.env.IOS_SIMULATOR,
|
|
90
|
-
process.env.ANDROID_DEVICE,
|
|
91
|
-
process.env.ADB_SERIAL,
|
|
92
|
-
].filter((value) => typeof value === 'string' && value.length > 0);
|
|
93
|
-
for (const preferredDevice of preferredDevices) {
|
|
94
|
-
const match = status.find((entry) => entry?.deviceName === preferredDevice);
|
|
95
|
-
if (match) return match;
|
|
96
|
-
}
|
|
97
|
-
return (
|
|
98
|
-
status.find((entry) => entry?.account) ??
|
|
99
|
-
status.find((entry) => entry && typeof entry === 'object') ??
|
|
100
|
-
null
|
|
101
|
-
);
|
|
102
|
-
}
|
|
80
|
+
// selectedStatus: shared pin-aware entry selection (see selectBridgeStatusEntry).
|
|
81
|
+
const selectedStatus = selectBridgeStatusEntry;
|
|
103
82
|
|
|
104
83
|
function hasSelectedAccount(status, input) {
|
|
105
84
|
return Boolean(selectedStatus(status, input)?.account);
|
|
@@ -112,7 +91,10 @@ function setupWalletScript() {
|
|
|
112
91
|
return fileURLToPath(new URL('../../../../adapters/mobile/bridge-runtime/setup-wallet.sh', import.meta.url));
|
|
113
92
|
}
|
|
114
93
|
|
|
115
|
-
function runSetupWallet(input, fixture) {
|
|
94
|
+
async function runSetupWallet(input, fixture) {
|
|
95
|
+
// bridgeEnv is async (it may shell adb to resolve the Metro device name);
|
|
96
|
+
// spreading its un-awaited Promise would hand the child an almost-empty env.
|
|
97
|
+
const env = await bridgeEnv(input);
|
|
116
98
|
return new Promise((resolve, reject) => {
|
|
117
99
|
const timeoutMs = Number(
|
|
118
100
|
input.node?.setup_timeout_ms ?? input.node?.timeout_ms ?? 120000,
|
|
@@ -122,7 +104,7 @@ function runSetupWallet(input, fixture) {
|
|
|
122
104
|
[setupWalletScript(), '--fixture', fixture.absolutePath],
|
|
123
105
|
{
|
|
124
106
|
cwd: input.context.projectRoot,
|
|
125
|
-
env: { ...
|
|
107
|
+
env: { ...env, CDP_TIMEOUT: String(timeoutMs), APP_ROOT: input.context.projectRoot },
|
|
126
108
|
stdio: ['ignore', 'pipe', 'pipe'],
|
|
127
109
|
},
|
|
128
110
|
);
|
|
@@ -135,9 +117,12 @@ function runSetupWallet(input, fixture) {
|
|
|
135
117
|
if (settled) return;
|
|
136
118
|
settled = true;
|
|
137
119
|
child.kill('SIGTERM');
|
|
138
|
-
|
|
139
|
-
|
|
140
|
-
|
|
120
|
+
// Always escalate after the grace period: child.killed only records
|
|
121
|
+
// that kill() was CALLED, not that the process exited, so gating
|
|
122
|
+
// SIGKILL on it never fires and a SIGTERM-ignoring child leaks. The
|
|
123
|
+
// close listener cancels the escalation when the child exits in time.
|
|
124
|
+
const killTimer = setTimeout(() => child.kill('SIGKILL'), 1000);
|
|
125
|
+
child.once('close', () => clearTimeout(killTimer));
|
|
141
126
|
reject(
|
|
142
127
|
new Error(
|
|
143
128
|
`Mobile setup-wallet.sh timed out after ${timeoutMs}ms.`,
|
|
@@ -11,6 +11,7 @@
|
|
|
11
11
|
"watch_logs",
|
|
12
12
|
"index_artifacts",
|
|
13
13
|
"end",
|
|
14
|
+
"call",
|
|
14
15
|
"ui.navigate",
|
|
15
16
|
"ui.press",
|
|
16
17
|
"ui.key_press",
|
|
@@ -724,6 +725,18 @@
|
|
|
724
725
|
}
|
|
725
726
|
}
|
|
726
727
|
]
|
|
728
|
+
},
|
|
729
|
+
"call": {
|
|
730
|
+
"description": "Invoke a named sub-recipe flow by ref and run its steps inline.",
|
|
731
|
+
"examples": [
|
|
732
|
+
{
|
|
733
|
+
"node": {
|
|
734
|
+
"action": "call",
|
|
735
|
+
"ref": "mydev.open_perps_setup",
|
|
736
|
+
"intent": "Run a personal Perps setup flow segment"
|
|
737
|
+
}
|
|
738
|
+
}
|
|
739
|
+
]
|
|
727
740
|
}
|
|
728
741
|
},
|
|
729
742
|
"custom_actions": [
|
|
@@ -11,6 +11,7 @@
|
|
|
11
11
|
"watch_logs",
|
|
12
12
|
"index_artifacts",
|
|
13
13
|
"end",
|
|
14
|
+
"call",
|
|
14
15
|
"ui.navigate",
|
|
15
16
|
"ui.press",
|
|
16
17
|
"ui.key_press",
|
|
@@ -724,6 +725,18 @@
|
|
|
724
725
|
}
|
|
725
726
|
}
|
|
726
727
|
]
|
|
728
|
+
},
|
|
729
|
+
"call": {
|
|
730
|
+
"description": "Invoke a named sub-recipe flow by ref and run its steps inline.",
|
|
731
|
+
"examples": [
|
|
732
|
+
{
|
|
733
|
+
"node": {
|
|
734
|
+
"action": "call",
|
|
735
|
+
"ref": "mydev.open_perps_setup",
|
|
736
|
+
"intent": "Run a personal Perps setup flow segment"
|
|
737
|
+
}
|
|
738
|
+
}
|
|
739
|
+
]
|
|
727
740
|
}
|
|
728
741
|
},
|
|
729
742
|
"custom_actions": [
|
|
@@ -0,0 +1,50 @@
|
|
|
1
|
+
{
|
|
2
|
+
"schema_version": 1,
|
|
3
|
+
"title": "MetaMask Mobile Perps performance flow",
|
|
4
|
+
"description": "Canonical measured flow for MetaMask Mobile Perps: unlock, open the Perps market list, read live state, and open a market's detail — one node per user-visible step so operators can diff per-node durations across runs. Timings live in trace.json as each node's duration; there is no built-in benchmark verb. Run pinned so numbers are comparable: `mm-harness run perps-performance --device <serial> --heal off`. Optional testnet order placement is intentionally omitted: the recipe DSL has no cheap skippable/conditional node, so a live order would make the flow destructive and non-repeatable; add metamask.perps.place_order + close in a personal copy when you want to measure the trade path.",
|
|
5
|
+
"validate": {
|
|
6
|
+
"workflow": {
|
|
7
|
+
"entry": "ensure-unlocked",
|
|
8
|
+
"nodes": {
|
|
9
|
+
"ensure-unlocked": {
|
|
10
|
+
"action": "metamask.wallet.ensure_unlocked",
|
|
11
|
+
"intent": "Unlock the wallet before timing the Perps journey",
|
|
12
|
+
"detail": "Unlock only if the app is currently locked so the first measured node starts from a stable, signed-in state.",
|
|
13
|
+
"flow": "setup",
|
|
14
|
+
"next": "open-perps-list"
|
|
15
|
+
},
|
|
16
|
+
"open-perps-list": {
|
|
17
|
+
"action": "ui.navigate",
|
|
18
|
+
"page": "perps",
|
|
19
|
+
"intent": "Open the Perps market list screen",
|
|
20
|
+
"detail": "Navigate to the Perps markets list via the stable page alias; its node duration measures market-list render time.",
|
|
21
|
+
"flow": "perps",
|
|
22
|
+
"next": "read-positions"
|
|
23
|
+
},
|
|
24
|
+
"read-positions": {
|
|
25
|
+
"action": "metamask.perps.read_positions",
|
|
26
|
+
"intent": "Read the live Perps positions shown on the market list",
|
|
27
|
+
"detail": "Read live Perps positions through the controller; its node duration measures how long live account state takes to resolve. Mobile has no read_markets action, so read_positions is the canonical read of on-screen live state.",
|
|
28
|
+
"flow": "perps",
|
|
29
|
+
"next": "open-market-detail"
|
|
30
|
+
},
|
|
31
|
+
"open-market-detail": {
|
|
32
|
+
"action": "ui.navigate",
|
|
33
|
+
"page": "perps-market",
|
|
34
|
+
"market": "BTC",
|
|
35
|
+
"intent": "Open the BTC Perps market detail screen",
|
|
36
|
+
"detail": "Navigate to the BTC market detail via the stable page alias; its node duration measures market-detail render time.",
|
|
37
|
+
"flow": "perps",
|
|
38
|
+
"next": "end"
|
|
39
|
+
},
|
|
40
|
+
"end": {
|
|
41
|
+
"action": "end",
|
|
42
|
+
"status": "pass",
|
|
43
|
+
"intent": "Finish the measured Perps performance flow",
|
|
44
|
+
"detail": "Terminal node; the passing run's trace.json holds the per-node durations to diff across runs.",
|
|
45
|
+
"flow": "complete"
|
|
46
|
+
}
|
|
47
|
+
}
|
|
48
|
+
}
|
|
49
|
+
}
|
|
50
|
+
}
|