@deeeed/metamask-harness 0.9.1 → 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 CHANGED
@@ -1,5 +1,16 @@
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
+
3
14
  ## 0.9.1 - 2026-07-07
4
15
 
5
16
  ### Fixed
@@ -114,9 +114,24 @@ async function discoverTarget(port) {
114
114
  /bridgeless|hermes/i.test(t.description || '')),
115
115
  );
116
116
 
117
- // Filter by device name if IOS_SIMULATOR is set
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,20 +140,13 @@ async function discoverTarget(port) {
125
140
  }
126
141
  }
127
142
 
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();
138
- const androidDevice = loadAndroidDevice();
139
- const adbSerial = process.env.ADB_SERIAL || process.env.ANDROID_SERIAL || '';
140
-
141
- if (candidates.length > 1) {
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) {
142
150
  let androidFiltered = [];
143
151
 
144
152
  if (androidTargetName) {
@@ -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
- if (!child.killed) child.kill("SIGKILL");
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
- for (const name of [`${recipeArg}.${adapter}.recipe.json`, `${recipeArg}.recipe.json`, recipeArg]) {
147
- const file = recipePath(name);
148
- if (isRecipeFile(file)) return { recipeFile: file };
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 names = libraryRecipeNames(adapter);
152
- return {
153
- notFound: `recipe not found: ${recipeArg} \u2014 not a file, and no packaged library recipe matched. ` + (names.length > 0 ? `Library recipes for ${adapter}: ${names.join(", ")} (mm-harness run <name>).` : `The packaged library has no recipes for ${adapter}.`)
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 resolved = resolveRunRecipeArg(recipeArg, adapter);
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 = { recipe: void 0, recipeFile, findings: [], errorCount: 0, manifestOk: false, schemaValid: false };
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 { recipe, recipeFile, findings, errorCount, manifestOk, schemaValid: validation.status === "valid" };
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();
@@ -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 = await resolveMetaMaskLibrarySources(optionString(options, "library"));
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
- if (!child.killed) child.kill("SIGKILL");
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) => {
@@ -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.
@@ -92,6 +92,18 @@ export async function bridgeEnv(input) {
92
92
  if (model) {
93
93
  env.ANDROID_TARGET_DEVICE_NAME = model;
94
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 = '';
95
107
  }
96
108
  return env;
97
109
  }
@@ -105,6 +117,56 @@ function resolveMobileTarget(input) {
105
117
  return { watcherPort, iosSimulator, androidDevice, adbSerial };
106
118
  }
107
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
+
108
170
  // Commands where a transient undefined/empty stdout means "not yet settled", not failure.
109
171
  // get-route returns undefined mid-navigation when the route state is momentarily unavailable;
110
172
  // waitForRoute polls through these nulls rather than aborting on a parse error.
@@ -129,9 +191,12 @@ export async function bridgeCommand(input, args) {
129
191
  if (settled) return;
130
192
  settled = true;
131
193
  child.kill('SIGTERM');
132
- setTimeout(() => {
133
- if (!child.killed) child.kill('SIGKILL');
134
- }, 1000);
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));
135
200
  resolve({ exitCode: null, stdout, stderr, timedOut: true, timeoutMs });
136
201
  }, timeoutMs)
137
202
  : null;
@@ -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
- function selectedStatus(status, input) {
38
- if (!Array.isArray(status)) {
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
- function selectedStatus(status, input) {
4
- if (!Array.isArray(status)) {
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
- function selectedStatus(status, input) {
81
- if (!Array.isArray(status)) {
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);
@@ -138,9 +117,12 @@ async function runSetupWallet(input, fixture) {
138
117
  if (settled) return;
139
118
  settled = true;
140
119
  child.kill('SIGTERM');
141
- setTimeout(() => {
142
- if (!child.killed) child.kill('SIGKILL');
143
- }, 1000);
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));
144
126
  reject(
145
127
  new Error(
146
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
+ }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@deeeed/metamask-harness",
3
- "version": "0.9.1",
3
+ "version": "0.10.0",
4
4
  "type": "module",
5
5
  "bin": {
6
6
  "mm-harness": "bin/mm-harness"