@deeeed/metamask-harness 0.33.2 → 0.34.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (54) hide show
  1. package/CHANGELOG.md +31 -0
  2. package/adapters/mobile/bridge-runtime/cdp-bridge.cjs +18 -2
  3. package/adapters/mobile/bridge-runtime/lib/target-discovery.cjs +83 -23
  4. package/dist/adapters/mobile/prepare.js +14 -0
  5. package/dist/adapters/mobile/video-recorder.js +282 -0
  6. package/dist/adapters.js +225 -35
  7. package/dist/command-contract.js +1 -0
  8. package/dist/commands/device-target.js +17 -6
  9. package/dist/commands/launch/index.js +27 -5
  10. package/dist/commands/launch/mobile.js +9 -2
  11. package/dist/commands/run-engine.js +34 -2
  12. package/dist/devices.js +9 -2
  13. package/dist/heal-bounds.js +10 -0
  14. package/dist/mm-harness-cli.js +4 -2
  15. package/dist/recipe-security.js +1 -0
  16. package/dist/recording-target.js +23 -5
  17. package/dist/runner.js +107 -6
  18. package/dist/runtime-context.js +33 -1
  19. package/docs/RECIPES.md +41 -0
  20. package/library/actions/mobile/app/network-control.mjs +135 -0
  21. package/library/actions/mobile/app/network.mjs +4 -0
  22. package/library/actions/mobile/perps/capture_performance.mjs +4 -0
  23. package/library/actions/mobile/perps/performance-capture.mjs +383 -0
  24. package/library/actions/mobile/perps/perps.mjs +16 -0
  25. package/library/actions/mobile/platform/bridge.mjs +229 -13
  26. package/library/actions/mobile/wallet/ensure_unlocked.mjs +27 -6
  27. package/library/actions/mobile/wallet/select_account.mjs +21 -17
  28. package/library/manifests/mobile.action-manifest.json +130 -1
  29. package/library/recipes/mobile/perps/performance.homepage.android-background-reconnect.recipe.json +159 -0
  30. package/library/recipes/mobile/perps/performance.homepage.android-background-short.recipe.json +157 -0
  31. package/library/recipes/mobile/perps/performance.homepage.android-cold-disk-cache.recipe.json +147 -0
  32. package/library/recipes/mobile/perps/performance.homepage.android-cold-no-cache.recipe.json +137 -0
  33. package/library/recipes/mobile/perps/performance.homepage.android-network-recovery.recipe.json +149 -0
  34. package/library/recipes/mobile/perps/performance.homepage.ios-background-reconnect.recipe.json +110 -0
  35. package/library/recipes/mobile/perps/performance.homepage.ios-background-short.recipe.json +108 -0
  36. package/library/recipes/mobile/perps/performance.homepage.ios-cold-disk-cache.recipe.json +122 -0
  37. package/library/recipes/mobile/perps/performance.homepage.ios-cold-no-cache.recipe.json +95 -0
  38. package/package.json +1 -1
  39. package/scripts/site-contrast.mjs +34 -1
  40. package/site/architecture.html +12 -2
  41. package/site/assets/style.css +20 -1
  42. package/site/cheatsheet.html +7 -6
  43. package/site/index.html +119 -77
  44. package/site/perps.html +195 -0
  45. package/site/recipes.html +23 -1
  46. package/site/reviewers.html +2 -1
  47. package/site/tutorials/index.html +2 -1
  48. package/site/tutorials/v1.html +3 -2
  49. package/site/tutorials/v2.html +6 -5
  50. package/site/tutorials/v3.html +43 -2
  51. package/site/tutorials/v4.html +2 -1
  52. package/site/tutorials/v5.html +2 -1
  53. package/site/tutorials/v6.html +2 -1
  54. package/site/tutorials/v7.html +2 -1
package/dist/adapters.js CHANGED
@@ -1,11 +1,15 @@
1
1
  import http from "node:http";
2
+ import { execFile } from "node:child_process";
3
+ import { promisify } from "node:util";
2
4
  import { compatibilityMode, fixtureSummary, repoShape } from "./doctor.js";
3
5
  import {
4
6
  runLiveAdapterScript
5
7
  } from "./live-adapter-contract.js";
6
8
  import { withExtensionPage } from "../library/actions/extension/platform/cdp.mjs";
7
- import { bridgeCommand, evalSync, MOBILE_BRIDGE_ERROR_CODES, selectBridgeStatusEntry, simulatorScreenshot } from "../library/actions/mobile/platform/bridge.mjs";
9
+ import { bridgeCommand, evalAsync, evalSync, MOBILE_BRIDGE_ERROR_CODES, selectBridgeStatusEntry, simulatorScreenshot } from "../library/actions/mobile/platform/bridge.mjs";
8
10
  import { observeNativeUi } from "../library/actions/mobile/platform/observe-ui.mjs";
11
+ import { resolveMobileToolPath } from "../library/actions/mobile/platform/tool-paths.mjs";
12
+ const execFileAsync = promisify(execFile);
9
13
  const NATIVE_PROVIDER_UI_ACTIONS = /* @__PURE__ */ new Set([
10
14
  "ui.swipe",
11
15
  "ui.pan",
@@ -34,8 +38,10 @@ const LIVE_ONLY_PERPS_ACTIONS = /* @__PURE__ */ new Set([
34
38
  "metamask.perps.close_orders",
35
39
  "metamask.perps.ensure_orders",
36
40
  "metamask.perps.assert_orders",
41
+ "metamask.perps.clear_performance_caches",
37
42
  "metamask.perps.start_state",
38
- "metamask.perps.teardown_state"
43
+ "metamask.perps.teardown_state",
44
+ "metamask.perps.capture_performance"
39
45
  ]);
40
46
  const CORE_ONLY_PERPS_ACTIONS = /* @__PURE__ */ new Set(["metamask.perps.read_account"]);
41
47
  const LIVE_ONLY_WALLET_ACTIONS = /* @__PURE__ */ new Set([
@@ -191,6 +197,8 @@ function createMetaMaskSemanticAdapters(platform, declaredCustomActions = [], pr
191
197
  "metamask.perps.assert_orders",
192
198
  "metamask.perps.start_state",
193
199
  "metamask.perps.teardown_state",
200
+ "metamask.perps.capture_performance",
201
+ ...platform === "mobile" ? ["metamask.perps.clear_performance_caches"] : [],
194
202
  // read_account is core-only: only the headless core adapter implements it.
195
203
  ...platform === "core" ? ["metamask.perps.read_account"] : []
196
204
  ];
@@ -238,43 +246,107 @@ function mobileProbeOutput(status, input, projectRoot) {
238
246
  function mobileBridgePath(_projectRoot) {
239
247
  return process.env.METAMASK_RECIPE_MOBILE_BRIDGE_SCRIPT || "runner:adapters/mobile/bridge-runtime/cdp-bridge.cjs";
240
248
  }
249
+ function resolveMobileWaitTarget(payload) {
250
+ const testId = optionalScalarText(
251
+ payload.test_id ?? payload.testID,
252
+ "Mobile ui.wait_for.test_id"
253
+ );
254
+ const expectedText = optionalScalarText(payload.text, "ui.wait_for.text");
255
+ if (!testId && !expectedText) {
256
+ throw new Error(
257
+ "Mobile ui.wait_for requires one of: test_id, testID, text."
258
+ );
259
+ }
260
+ const expected = scalarText(
261
+ payload.expected,
262
+ "ui.wait_for.expected",
263
+ "present"
264
+ ).toLowerCase();
265
+ const textMatch = scalarText(
266
+ payload.text_match ?? payload.textMatch,
267
+ "ui.wait_for.text_match",
268
+ "contains"
269
+ ).toLowerCase();
270
+ if (!["contains", "exact"].includes(textMatch)) {
271
+ throw new Error("ui.wait_for.text_match must be contains or exact.");
272
+ }
273
+ if (!testId && textMatch === "exact") {
274
+ throw new Error(
275
+ "Mobile text-only ui.wait_for supports text_match=contains; exact matching also requires test_id/testID."
276
+ );
277
+ }
278
+ return {
279
+ testId,
280
+ expectedText,
281
+ expected,
282
+ textMatch,
283
+ visibility: expected === "visible" || expected === "hidden" ? "viewport" : "tree"
284
+ };
285
+ }
241
286
  async function waitForMobileTarget(input, payload) {
242
287
  const timeoutMs = Number(payload.timeout_ms ?? payload.timeoutMs ?? 1e4);
243
288
  const deadline = Date.now() + timeoutMs;
244
- const testId = firstScalarText(payload, ["test_id", "testID"], "Mobile ui.wait_for");
245
- const expected = scalarText(payload.expected, "ui.wait_for.expected", "present").toLowerCase();
246
- const expectedText = optionalScalarText(payload.text, "ui.wait_for.text");
247
- const textMatch = scalarText(payload.text_match ?? payload.textMatch, "ui.wait_for.text_match", "contains").toLowerCase();
248
- const textExpression = `(function(){
289
+ const { testId, expectedText, expected, textMatch, visibility } = resolveMobileWaitTarget(payload);
290
+ const query = {
291
+ ...testId ? { testId } : {},
292
+ ...expectedText && textMatch === "contains" ? { textContains: expectedText } : {},
293
+ visibility
294
+ };
295
+ const queryExpression = `(function(){
249
296
  const api = globalThis.__AGENTIC__;
250
- if (!api?.getTextByTestId) return null;
251
- return api.getTextByTestId(${JSON.stringify(testId)});
297
+ if (!api || typeof api.queryUiTarget !== 'function') {
298
+ return Promise.resolve(JSON.stringify({ unsupported: true }));
299
+ }
300
+ return api.queryUiTarget(${JSON.stringify(query)}).then(function(result){
301
+ return JSON.stringify(result);
302
+ });
252
303
  })()`;
253
- const expression = `Boolean(globalThis.__AGENTIC__?.findFiberByTestId?.(${JSON.stringify(testId)}))`;
254
- let lastValue = null;
304
+ const textExpression = testId ? `(function(){
305
+ const api = globalThis.__AGENTIC__;
306
+ if (!api?.getTextByTestId) return null;
307
+ return api.getTextByTestId(${JSON.stringify(testId)});
308
+ })()` : null;
309
+ let lastResult = null;
255
310
  let lastText = null;
256
311
  const expectsAbsent = expected === "absent" || expected === "hidden" || expected === "not_present";
257
312
  while (Date.now() <= deadline) {
258
- lastValue = await evalSync(input, expression);
259
- const present = Boolean(lastValue);
260
- if (expectsAbsent && !present) {
261
- return { matched: true, testId, expected, present };
313
+ const result = await evalAsync(input, queryExpression);
314
+ if (!isRecord(result) || result.unsupported === true) {
315
+ throw new Error(
316
+ "Mobile ui.wait_for requires the current __AGENTIC__.queryUiTarget bridge for text-only or viewport-visible assertions."
317
+ );
262
318
  }
263
- if (!expectsAbsent && present) {
264
- if (!expectedText) {
265
- return { matched: true, testId, expected, present };
266
- }
319
+ lastResult = result;
320
+ const present = result.present === true;
321
+ const visible = result.visible === true;
322
+ let textMatched = !expectedText || textMatch === "contains";
323
+ if (expectedText && textMatch === "exact" && textExpression) {
267
324
  lastText = await evalSync(input, textExpression);
268
- const text = traceText(lastText);
269
- const textMatched = textMatch === "exact" ? text === expectedText : text.includes(expectedText);
270
- if (textMatched) {
271
- return { matched: true, testId, expected, present, text, textMatch };
272
- }
325
+ textMatched = traceText(lastText) === expectedText;
326
+ }
327
+ const matched = expected === "visible" ? visible && textMatched : expected === "hidden" ? !visible : expectsAbsent ? !present : present && textMatched;
328
+ if (matched) {
329
+ return {
330
+ matched: true,
331
+ ...testId ? { testId } : {},
332
+ ...expectedText ? { text: expectedText, textMatch } : {},
333
+ expected,
334
+ present,
335
+ visible,
336
+ visibility,
337
+ ...isRecord(result.rect) ? { rect: result.rect } : {}
338
+ };
273
339
  }
274
340
  await sleep(250);
275
341
  }
276
- const textReason = expectedText ? ` and text ${textMatch} ${JSON.stringify(expectedText)}; last text=${JSON.stringify(lastText)}` : "";
277
- throw new Error(`Timed out waiting for mobile testID ${testId} to be ${expected}${textReason}; last present=${Boolean(lastValue)}.`);
342
+ const target = [
343
+ testId ? `testID ${JSON.stringify(testId)}` : "",
344
+ expectedText ? `text ${textMatch} ${JSON.stringify(expectedText)}` : ""
345
+ ].filter(Boolean).join(" with ");
346
+ const exactReason = textMatch === "exact" ? `; last text=${JSON.stringify(lastText)}` : "";
347
+ throw new Error(
348
+ `Timed out waiting for mobile ${target} to be ${expected}; last result=${JSON.stringify(lastResult)}${exactReason}.`
349
+ );
278
350
  }
279
351
  const MOBILE_BRIDGE_HANDLERS = {
280
352
  screenshot: handleMobileScreenshot,
@@ -354,13 +426,73 @@ async function handleMobileScroll(payload, context) {
354
426
  const input = mobileUiInput(context, "scroll", payload);
355
427
  const testId = optionalScalarText(payload.test_id ?? payload.testID, "ui.scroll.test_id");
356
428
  const offset = scalarText(payload.offset ?? payload.delta_y ?? payload.deltaY, "ui.scroll.offset", "600");
357
- const args = testId ? ["scroll-view", "--test-id", testId, "--offset", offset, animatedFlag(payload)] : ["scroll-view", "--offset", offset, animatedFlag(payload)];
429
+ const intoView = payload.scroll_into_view === true || payload.into_view === true;
430
+ if (testId && intoView) {
431
+ const before = await queryMobileViewportTarget(input, testId);
432
+ if (before.visible === true) {
433
+ return {
434
+ ok: true,
435
+ testId,
436
+ intoView: true,
437
+ alreadyVisible: true,
438
+ rect: before.rect
439
+ };
440
+ }
441
+ }
442
+ const args = testId ? [
443
+ "scroll-view",
444
+ "--test-id",
445
+ testId,
446
+ "--offset",
447
+ offset,
448
+ ...intoView ? ["--into-view"] : [],
449
+ animatedFlag(payload)
450
+ ] : ["scroll-view", "--offset", offset, animatedFlag(payload)];
358
451
  const result = await bridgeCommand(input, args);
452
+ if (testId && intoView) {
453
+ const after = await waitForMobileViewportTarget(
454
+ input,
455
+ testId,
456
+ Number(payload.timeout_ms ?? payload.timeoutMs ?? 5e3)
457
+ );
458
+ if (after.visible !== true) {
459
+ throw new Error(
460
+ `ui.scroll did not bring mobile testID ${JSON.stringify(testId)} into the viewport; last result=${JSON.stringify(after)}.`
461
+ );
462
+ }
463
+ }
359
464
  return {
360
465
  ...isRecord(result) ? result : { result },
361
- intoView: payload.scroll_into_view === true || payload.into_view === true
466
+ intoView
362
467
  };
363
468
  }
469
+ async function queryMobileViewportTarget(input, testId) {
470
+ const expression = `(function(){
471
+ const api = globalThis.__AGENTIC__;
472
+ if (!api || typeof api.queryUiTarget !== 'function') {
473
+ return Promise.resolve(JSON.stringify({ unsupported: true }));
474
+ }
475
+ return api.queryUiTarget(${JSON.stringify({ testId, visibility: "viewport" })}).then(function(result){
476
+ return JSON.stringify(result);
477
+ });
478
+ })()`;
479
+ const result = await evalAsync(input, expression);
480
+ if (!isRecord(result) || result.unsupported === true) {
481
+ throw new Error(
482
+ "Mobile ui.scroll scroll_into_view requires the current __AGENTIC__.queryUiTarget bridge."
483
+ );
484
+ }
485
+ return result;
486
+ }
487
+ async function waitForMobileViewportTarget(input, testId, timeoutMs) {
488
+ const deadline = Date.now() + Math.max(0, Math.min(timeoutMs, 5e3));
489
+ let result = await queryMobileViewportTarget(input, testId);
490
+ while (result.visible !== true && Date.now() < deadline) {
491
+ await sleep(100);
492
+ result = await queryMobileViewportTarget(input, testId);
493
+ }
494
+ return result;
495
+ }
364
496
  async function handleMobileWaitFor(payload, context) {
365
497
  return waitForMobileTarget(mobileUiInput(context, "waitFor", payload), payload);
366
498
  }
@@ -543,13 +675,10 @@ async function executeNativeProviderAction(action, node, context, createTranspor
543
675
  `${action} requires one selected ${platform} device. Next: mm-harness status --adapter mobile --all-devices --json`
544
676
  );
545
677
  }
546
- const app = platform === "android" ? context.env.ANDROID_PACKAGE_ID ?? process.env.ANDROID_PACKAGE_ID : context.env.IOS_BUNDLE_ID ?? process.env.IOS_BUNDLE_ID;
547
- if (!app) {
548
- const variable = platform === "android" ? "ANDROID_PACKAGE_ID" : "IOS_BUNDLE_ID";
549
- throw new Error(
550
- `${action} requires the selected MetaMask app id. Next: export ${variable}=<installed-app-id>`
551
- );
678
+ if (platform === "android" && action === "ui.swipe" && isCoordinateTarget(node.target)) {
679
+ return executeAndroidCoordinateSwipe(node, String(device));
552
680
  }
681
+ const app = platform === "android" ? context.env.ANDROID_PACKAGE_ID ?? process.env.ANDROID_PACKAGE_ID ?? "io.metamask" : context.env.IOS_BUNDLE_ID ?? process.env.IOS_BUNDLE_ID ?? "io.metamask.MetaMask";
553
682
  const session = `mm-harness-${process.pid}-${context.nodeId}`.replace(/[^a-zA-Z0-9._-]/gu, "-");
554
683
  let transport;
555
684
  try {
@@ -585,6 +714,66 @@ async function executeNativeProviderAction(action, node, context, createTranspor
585
714
  if (closeError) throw closeError;
586
715
  return output;
587
716
  }
717
+ function isCoordinateTarget(value) {
718
+ return isRecord(value) && typeof value.x === "number" && Number.isFinite(value.x) && typeof value.y === "number" && Number.isFinite(value.y);
719
+ }
720
+ async function executeAndroidCoordinateSwipe(node, device) {
721
+ const target = node.target;
722
+ const direction = String(node.direction ?? "").toLowerCase();
723
+ if (!["up", "down", "left", "right"].includes(direction)) {
724
+ throw new Error("ui.swipe.direction must be up, down, left, or right.");
725
+ }
726
+ const distance = Number(node.distance ?? 300);
727
+ const durationMs = Number(node.duration_ms ?? 300);
728
+ if (!Number.isFinite(distance) || distance <= 0) {
729
+ throw new Error("ui.swipe.distance must be a positive number.");
730
+ }
731
+ if (!Number.isFinite(durationMs) || durationMs <= 0) {
732
+ throw new Error("ui.swipe.duration_ms must be a positive number.");
733
+ }
734
+ const start = { x: Math.round(target.x), y: Math.round(target.y) };
735
+ const end = {
736
+ x: Math.round(start.x + (direction === "left" ? -distance : direction === "right" ? distance : 0)),
737
+ y: Math.round(start.y + (direction === "up" ? -distance : direction === "down" ? distance : 0))
738
+ };
739
+ const adbPath = resolveMobileToolPath("adb", { required: true });
740
+ try {
741
+ await execFileAsync(
742
+ adbPath,
743
+ [
744
+ "-s",
745
+ device,
746
+ "shell",
747
+ "input",
748
+ "swipe",
749
+ String(start.x),
750
+ String(start.y),
751
+ String(end.x),
752
+ String(end.y),
753
+ String(Math.round(durationMs))
754
+ ],
755
+ { encoding: "utf8", timeout: Math.round(durationMs) + 1e4 }
756
+ );
757
+ } catch (error) {
758
+ throw new Error(
759
+ `Android ui.swipe failed on ${device}: ${error instanceof Error ? error.message : String(error)}`
760
+ );
761
+ }
762
+ const settleMs = node.settle === false ? 0 : 300;
763
+ if (settleMs > 0) await sleep(settleMs);
764
+ return {
765
+ action: "ui.swipe",
766
+ resolvedStart: start,
767
+ resolvedEnd: end,
768
+ backend: "adb-input-swipe",
769
+ segments: 1,
770
+ settlement: {
771
+ method: "bounded-post-gesture",
772
+ waitedMs: settleMs,
773
+ lifecycleNeutral: true
774
+ }
775
+ };
776
+ }
588
777
  function mobileNativePlatform(env) {
589
778
  if (env.PLATFORM === "android" || env.PLATFORM === "ios") return env.PLATFORM;
590
779
  if (env.ADB_SERIAL || env.ANDROID_SERIAL) return "android";
@@ -662,5 +851,6 @@ export {
662
851
  createMetaMaskUiTransport,
663
852
  hideMobileHudOnTeardown,
664
853
  isMobileHudLifecycleSkip,
665
- normalizeUiWaitNode
854
+ normalizeUiWaitNode,
855
+ resolveMobileWaitTarget
666
856
  };
@@ -228,6 +228,7 @@ const PUBLIC_COMMAND_CONTRACTS = {
228
228
  launch: {
229
229
  options: options(HELP, JSON, JSON_STREAM, TARGET, ADAPTER, MOBILE_PLATFORM, DEVICE, RUNTIME_PORTS, {
230
230
  "--build": bool(),
231
+ "--clear-metro": bool(),
231
232
  "--verify": bool(),
232
233
  "--runway": bool(),
233
234
  "--watch": bool(),
@@ -1,7 +1,12 @@
1
1
  import { execFileSync } from "node:child_process";
2
2
  import { resolveMobileToolPath } from "../../library/actions/mobile/platform/tool-paths.mjs";
3
- import { listConnectedDevices } from "../devices.js";
3
+ import {
4
+ findAvailableIosSimulatorById,
5
+ listConnectedDevices
6
+ } from "../devices.js";
7
+ import { persistExplicitDevicePin } from "../runtime-context.js";
4
8
  import { optionString, shellQuoteArg, targetPath } from "./parse-args.js";
9
+ const DEVICE_SELECT_HINT = "Pass --device <name|udid|serial> to select one target. Do not shut down other connected devices to force selection.";
5
10
  function normalizeDeviceName(value) {
6
11
  return value.replace(/_/gu, " ").trim();
7
12
  }
@@ -20,6 +25,7 @@ function resolveAndroidModel(serial) {
20
25
  }
21
26
  }
22
27
  function setAndroidDeviceEnv(id, fallbackName) {
28
+ process.env.PLATFORM = "android";
23
29
  process.env.ADB_SERIAL = id;
24
30
  process.env.ANDROID_SERIAL = id;
25
31
  process.env.ANDROID_DEVICE = id;
@@ -29,7 +35,8 @@ function setAndroidDeviceEnv(id, fallbackName) {
29
35
  process.env.IOS_SIMULATOR = "";
30
36
  }
31
37
  function setIosDeviceEnv(id, fallbackName) {
32
- process.env.IOS_SIMULATOR = fallbackName ? normalizeDeviceName(fallbackName) : id;
38
+ process.env.PLATFORM = "ios";
39
+ process.env.IOS_SIMULATOR = fallbackName?.trim() || id;
33
40
  process.env.SIM_UDID = id;
34
41
  process.env.ADB_SERIAL = "";
35
42
  process.env.ANDROID_SERIAL = "";
@@ -113,13 +120,14 @@ function applyDeviceTargeting(command, adapter, options, opts) {
113
120
  }
114
121
  if (matched.platform === "android") setAndroidDeviceEnv(matched.id, matched.name);
115
122
  else setIosDeviceEnv(matched.id, matched.name);
123
+ persistExplicitDevicePin(targetPath(options), matched);
116
124
  return { ok: true };
117
125
  };
118
126
  const androidById = connected2.find((d) => d.platform === "android" && d.id === device);
119
127
  if (androidById) {
120
128
  return setResolvedDevice(androidById);
121
129
  }
122
- const iosById = connected2.find((d) => d.platform === "ios" && d.id === device);
130
+ const iosById = connected2.find((d) => d.platform === "ios" && d.id === device) ?? (command === "launch" && platformPreference === "ios" ? findAvailableIosSimulatorById(device) : void 0);
123
131
  if (iosById) {
124
132
  return setResolvedDevice(iosById);
125
133
  }
@@ -221,7 +229,8 @@ ${formatConnectedDevices(connected)}` : ` No devices are currently connected (a
221
229
  Connected devices:
222
230
  ${formatConnectedDevices(connected)}
223
231
  Add --device <id> to disambiguate, e.g.:${platformTargetable.map((d) => `
224
- --device ${d.id}`).join("")}`,
232
+ --device ${d.name ?? d.id}`).join("")}
233
+ ${DEVICE_SELECT_HINT}`,
225
234
  userAction: deviceRecovery(options, opts.rerun)
226
235
  };
227
236
  }
@@ -240,8 +249,10 @@ ${formatConnectedDevices(connected)}
240
249
  message: `${targetable.length} mobile devices available \u2014 ${command} needs exactly one target.
241
250
  Connected devices:
242
251
  ${formatConnectedDevices(connected)}
243
- ` + (selectedTargetable.length > 1 ? ` The current slot context selects more than one target; fix the slot device pins or add --device <id>.` : ` Add --device <id> to disambiguate, e.g.:${targetable.map((d) => `
244
- --device ${d.id}`).join("")}`),
252
+ ` + (selectedTargetable.length > 1 ? ` The current slot context selects more than one target; fix the slot device pins or add --device <id>.
253
+ ${DEVICE_SELECT_HINT}` : ` Add --device <id> to disambiguate, e.g.:${targetable.map((d) => `
254
+ --device ${d.name ?? d.id}`).join("")}
255
+ ${DEVICE_SELECT_HINT}`),
245
256
  userAction: deviceRecovery(options, opts.rerun)
246
257
  };
247
258
  }
@@ -44,6 +44,7 @@ const LAUNCH_BOOLEANS = /* @__PURE__ */ new Set([
44
44
  "sidepanel",
45
45
  "fullscreen",
46
46
  "runway",
47
+ "clearMetro",
47
48
  "json",
48
49
  "jsonStream"
49
50
  ]);
@@ -122,9 +123,13 @@ async function handleLaunchLocked(argv, stream) {
122
123
  const wantVerify = flag(options, "verify");
123
124
  const wantWatch = flag(options, "watch");
124
125
  const wantRunway = flag(options, "runway");
126
+ const wantClearMetro = flag(options, "clearMetro");
125
127
  if (wantRunway && adapter !== "mobile") {
126
128
  return launchUsage(jsonOutput, stream, "runway is mobile-only.", "drop --runway for the extension");
127
129
  }
130
+ if (wantClearMetro && adapter !== "mobile") {
131
+ return launchUsage(jsonOutput, stream, "--clear-metro is mobile-only.", "drop --clear-metro for the extension");
132
+ }
128
133
  const displayMode = flag(options, "sidepanel") && !flag(options, "fullscreen") ? "sidepanel" : "fullscreen";
129
134
  for (const portFlag of ["cdpPort", "watcherPort"]) {
130
135
  const value = str(options, portFlag);
@@ -160,7 +165,7 @@ async function handleLaunchLocked(argv, stream) {
160
165
  const watcherPort = str(options, "watcherPort") ?? process.env.WATCHER_PORT ?? "auto";
161
166
  writeInteractiveProgress(
162
167
  machine,
163
- `\u2192 mobile launch \u2014 ${mobileTarget} \xB7 ${tier === "build" ? "native build" : "quick readiness"} \xB7 ${device} \xB7 Metro ${watcherPort === "auto" ? "auto" : `:${watcherPort}`}`
168
+ `\u2192 mobile launch \u2014 ${mobileTarget} \xB7 ${tier === "build" ? "native build" : "quick readiness"} \xB7 ${device} \xB7 Metro ${watcherPort === "auto" ? "auto" : `:${watcherPort}`}${wantClearMetro ? " \xB7 clear cache" : ""}`
164
169
  );
165
170
  }
166
171
  const state = newHealState();
@@ -221,7 +226,17 @@ async function handleLaunchLocked(argv, stream) {
221
226
  }
222
227
  }
223
228
  stream.phase("launch");
224
- let attempt = await executeComposition(adapter, mobileTarget, tier, wantWatch, target, machine, displayMode);
229
+ let attempt = await executeComposition(
230
+ adapter,
231
+ mobileTarget,
232
+ tier,
233
+ wantWatch,
234
+ target,
235
+ machine,
236
+ displayMode,
237
+ false,
238
+ wantClearMetro
239
+ );
225
240
  if (attempt.status === 0) {
226
241
  return await finishLaunch(jsonOutput, machine, stream, adapter, mobileTarget, tier, displayMode, target, state, wantWatch, wantVerify);
227
242
  }
@@ -379,7 +394,7 @@ function applyLaunchEnvOverrides(options, adapter, mobileTarget, target) {
379
394
  if (adapter === "mobile" && (device || mobileTarget === "ios" || mobileTarget === "android")) {
380
395
  const targetingOptions = {
381
396
  ...options,
382
- ...mobileTarget && !device ? { platform: mobileTarget } : {}
397
+ ...mobileTarget ? { platform: mobileTarget } : {}
383
398
  };
384
399
  const result = applyDeviceTargeting("launch", adapter, targetingOptions, { gate: false, rerun: "" });
385
400
  if ("code" in result) {
@@ -447,9 +462,16 @@ function nativeInputsChanged(target, adapter) {
447
462
  return false;
448
463
  }
449
464
  }
450
- async function executeComposition(adapter, mobileTarget, tier, wantWatch, target, json, displayMode = "fullscreen", restartMobileApp = false) {
465
+ async function executeComposition(adapter, mobileTarget, tier, wantWatch, target, json, displayMode = "fullscreen", restartMobileApp = false, clearMobileMetro = false) {
451
466
  if (adapter === "mobile") {
452
- return launchMobile(target, mobileTarget, tier, json, restartMobileApp);
467
+ return launchMobile(
468
+ target,
469
+ mobileTarget,
470
+ tier,
471
+ json,
472
+ restartMobileApp,
473
+ clearMobileMetro
474
+ );
453
475
  }
454
476
  return launchExtension(target, tier, wantWatch, displayMode);
455
477
  }
@@ -1,11 +1,18 @@
1
1
  import { prepareMobile } from "../../adapters/mobile/prepare.js";
2
2
  import { ensureHarnessFresh } from "../../adapters/harness-freshness.js";
3
- async function launchMobile(target, mobileTarget, tier, json, restartApp = false) {
3
+ async function launchMobile(target, mobileTarget, tier, json, restartApp = false, clearMetro = false) {
4
4
  await ensureHarnessFresh(target, "mobile");
5
5
  const platform = mobileTarget ?? "ios";
6
6
  const watcherPort = process.env.WATCHER_PORT ? parseInt(process.env.WATCHER_PORT, 10) : void 0;
7
7
  const preflightMode = tier === "build" ? "auto" : "fast";
8
- return prepareMobile(target, { platform, json, watcherPort, preflightMode, restartApp });
8
+ return prepareMobile(target, {
9
+ platform,
10
+ json,
11
+ watcherPort,
12
+ preflightMode,
13
+ restartApp,
14
+ clearMetro
15
+ });
9
16
  }
10
17
  export {
11
18
  launchMobile
@@ -135,10 +135,12 @@ async function resolveRecipeExecution(adapter, recipe, artifactsDir, projectRoot
135
135
  process.env.METAMASK_RECIPE_LIVE_ADAPTER_DIR
136
136
  );
137
137
  const { createMetaMaskRunner } = await import("../runner.js");
138
+ const recipeDocument = runtimeRecipeDocument(recipe, absoluteRecipePath);
139
+ const suppressHudForLifecycleTiming = adapter === "mobile" && mobileRecipeRequiresUninterruptedLifecycleTiming(recipeDocument);
138
140
  const runner = await createMetaMaskRunner(adapter, manifest, {
139
141
  quietStdout: runtimeOptions.stdoutIsMachineContract === true,
140
142
  suppressLibraryResolutionLogs: runtimeOptions.suppressLibraryResolutionLogs,
141
- autoHud: trust.source && trust.source.trust !== "trusted" ? false : runtimeOptions.autoHud,
143
+ autoHud: suppressHudForLifecycleTiming ? false : trust.source && trust.source.trust !== "trusted" ? false : runtimeOptions.autoHud,
142
144
  onActionEvent: runtimeOptions.onActionEvent,
143
145
  actionSources,
144
146
  // A direct engineer invocation is the explicit trust boundary for a
@@ -169,6 +171,25 @@ async function resolveRecipeExecution(adapter, recipe, artifactsDir, projectRoot
169
171
  }
170
172
  };
171
173
  }
174
+ function runtimeRecipeDocument(recipe, absoluteRecipePath) {
175
+ if (typeof recipe !== "string") return recipe;
176
+ if (!absoluteRecipePath || !fs.existsSync(absoluteRecipePath)) return void 0;
177
+ try {
178
+ return JSON.parse(fs.readFileSync(absoluteRecipePath, "utf8"));
179
+ } catch {
180
+ return void 0;
181
+ }
182
+ }
183
+ function mobileRecipeRequiresUninterruptedLifecycleTiming(recipe) {
184
+ if (!isRecord(recipe)) return false;
185
+ const workflow = isRecord(recipe.workflow) ? recipe.workflow : void 0;
186
+ const nodes = workflow && isRecord(workflow.nodes) ? workflow.nodes : void 0;
187
+ if (!nodes) return false;
188
+ const actions = new Set(
189
+ Object.values(nodes).filter(isRecord).map((node) => node.action).filter((action) => typeof action === "string")
190
+ );
191
+ return actions.has("app.lifecycle") && actions.has("metamask.perps.capture_performance");
192
+ }
172
193
  function recipeProcessEnvironment() {
173
194
  const env = { ...process.env };
174
195
  delete env.MM_HARNESS_CHECKOUT_LOCK_TOKEN;
@@ -1126,11 +1147,20 @@ async function recoverRunInfra(adapter, target, json) {
1126
1147
  }
1127
1148
  if (adapter === "mobile") {
1128
1149
  const { launchMobile } = await import("./launch/mobile.js");
1129
- const platform = process.env.PLATFORM === "android" ? "android" : "ios";
1150
+ const platform = resolveMobileRecoveryPlatform();
1130
1151
  return launchMobile(target, platform, "quick", json);
1131
1152
  }
1132
1153
  return { status: 0, output: "headless run retry" };
1133
1154
  }
1155
+ function resolveMobileRecoveryPlatform(env = process.env) {
1156
+ if (env.PLATFORM === "android" || env.PLATFORM === "ios") return env.PLATFORM;
1157
+ const androidPinned = Boolean(
1158
+ env.ADB_SERIAL || env.ANDROID_SERIAL || env.ANDROID_TARGET_DEVICE_NAME || env.ANDROID_DEVICE
1159
+ );
1160
+ const iosPinned = Boolean(env.IOS_SIMULATOR || env.SIM_UDID);
1161
+ if (androidPinned && !iosPinned) return "android";
1162
+ return "ios";
1163
+ }
1134
1164
  function readRunFailureText(result) {
1135
1165
  try {
1136
1166
  const trace = JSON.parse(fs.readFileSync(result.tracePath, "utf8"));
@@ -1218,10 +1248,12 @@ export {
1218
1248
  ensureMobileProofRuntime,
1219
1249
  executeWithHealBounds,
1220
1250
  listRunnableRecipes,
1251
+ mobileRecipeRequiresUninterruptedLifecycleTiming,
1221
1252
  preflightRecipe,
1222
1253
  prepareHeal,
1223
1254
  recoverRunInfra,
1224
1255
  resolveMetaMaskLibrarySources,
1256
+ resolveMobileRecoveryPlatform,
1225
1257
  resolveRunRecipeArg,
1226
1258
  runOneNode,
1227
1259
  runRecipe,
package/dist/devices.js CHANGED
@@ -6,6 +6,12 @@ function listConnectedDevices(platform) {
6
6
  if (!platform || platform === "ios") devices.push(...listIosSimulators());
7
7
  return devices;
8
8
  }
9
+ function findAvailableIosSimulatorById(id) {
10
+ for (const simulator of listIosSimulators("available")) {
11
+ if (simulator.id === id) return simulator;
12
+ }
13
+ return void 0;
14
+ }
9
15
  function listAndroidDevices() {
10
16
  const adbPath = resolveMobileToolPath("adb");
11
17
  if (!adbPath) return [];
@@ -33,10 +39,10 @@ function listAndroidDevices() {
33
39
  }
34
40
  return devices;
35
41
  }
36
- function listIosSimulators() {
42
+ function listIosSimulators(scope = "booted") {
37
43
  let output;
38
44
  try {
39
- output = execFileSync("xcrun", ["simctl", "list", "devices", "booted", "-j"], {
45
+ output = execFileSync("xcrun", ["simctl", "list", "devices", scope, "-j"], {
40
46
  encoding: "utf8",
41
47
  timeout: 5e3,
42
48
  stdio: ["ignore", "pipe", "ignore"]
@@ -65,5 +71,6 @@ function listIosSimulators() {
65
71
  return devices;
66
72
  }
67
73
  export {
74
+ findAvailableIosSimulatorById,
68
75
  listConnectedDevices
69
76
  };
@@ -61,6 +61,7 @@ async function ensureOverlay(adapter, target, heal, state, json) {
61
61
  return { ok: true };
62
62
  }
63
63
  function classifyFailure(output) {
64
+ if (/SCREENSHOT_PROTECTED|screenshot is protected by FLAG_SECURE/iu.test(output)) return "screenshot-protected";
64
65
  if (/wallet|fixture|keyring|not seeded|\bsrp\b|password|onboard/iu.test(output)) return "wallet";
65
66
  if (/metro|cdp|chrome|bridge|bundle|packager|port\b|econnrefused|not reachable|watcher|dev client|websocket/iu.test(output)) {
66
67
  return "infra";
@@ -86,6 +87,15 @@ function checkHealBounds(target, output, state) {
86
87
  };
87
88
  }
88
89
  const failureClass = classifyFailure(output);
90
+ if (failureClass === "screenshot-protected") {
91
+ return {
92
+ code: "SCREENSHOT_PROTECTED",
93
+ exitCode: EXIT.runtime,
94
+ message: "the focused Android window intentionally blocks screenshots; a black capture cannot be used as evidence.",
95
+ userAction: "unlock or navigate to a non-protected screen, then rerun the screenshot action",
96
+ originalError
97
+ };
98
+ }
89
99
  if (failureClass === "wallet") {
90
100
  return {
91
101
  code: "WALLET_STATE_REQUIRED",