@deeeed/metamask-harness 0.40.1 → 0.41.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 (35) hide show
  1. package/CHANGELOG.md +16 -0
  2. package/adapters/manifest.json +8 -0
  3. package/adapters/mobile/bridge-runtime/cdp-bridge.cjs +5 -4
  4. package/adapters/mobile/bridge-runtime/lib/target-discovery.cjs +24 -5
  5. package/adapters/mobile/metro-config.cjs +94 -0
  6. package/adapters/mobile/start-metro.sh +13 -1
  7. package/dist/commands/device-target.js +3 -0
  8. package/dist/commands/run-engine.js +4 -14
  9. package/dist/heal-bounds.js +1 -0
  10. package/dist/live-adapter-contract.js +2 -1
  11. package/dist/mm-harness-cli.js +1 -1
  12. package/docs/RECIPES.md +25 -19
  13. package/library/actions/mobile/perps/capture_performance.mjs +2 -2
  14. package/library/actions/mobile/perps/performance-capture.mjs +375 -17
  15. package/library/actions/mobile/perps/perps.mjs +56 -10
  16. package/library/actions/mobile/platform/bridge.mjs +59 -1
  17. package/library/actions/mobile/wallet/ensure_unlocked.mjs +60 -26
  18. package/library/actions/mobile/wallet/import.mjs +2 -0
  19. package/library/actions/mobile/wallet/select_account.mjs +21 -2
  20. package/library/manifests/mobile.action-manifest.json +47 -6
  21. package/library/recipes/mobile/perps/performance.recipe.json +680 -26
  22. package/package.json +1 -1
  23. package/library/recipes/mobile/perps/performance.background-resume.recipe.json +0 -56
  24. package/library/recipes/mobile/perps/performance.cold-start.recipe.json +0 -56
  25. package/library/recipes/mobile/perps/performance.homepage.android-background-reconnect.recipe.json +0 -159
  26. package/library/recipes/mobile/perps/performance.homepage.android-background-short.recipe.json +0 -157
  27. package/library/recipes/mobile/perps/performance.homepage.android-cold-disk-cache.recipe.json +0 -165
  28. package/library/recipes/mobile/perps/performance.homepage.android-cold-no-cache.recipe.json +0 -139
  29. package/library/recipes/mobile/perps/performance.homepage.android-network-recovery.recipe.json +0 -149
  30. package/library/recipes/mobile/perps/performance.homepage.cold-position-sample.recipe.json +0 -120
  31. package/library/recipes/mobile/perps/performance.homepage.ios-background-reconnect.recipe.json +0 -111
  32. package/library/recipes/mobile/perps/performance.homepage.ios-background-short.recipe.json +0 -108
  33. package/library/recipes/mobile/perps/performance.homepage.ios-cold-disk-cache.recipe.json +0 -122
  34. package/library/recipes/mobile/perps/performance.homepage.ios-cold-no-cache.recipe.json +0 -95
  35. package/library/recipes/mobile/perps/performance.warm-start.recipe.json +0 -49
package/CHANGELOG.md CHANGED
@@ -2,6 +2,22 @@
2
2
 
3
3
  ## Unreleased
4
4
 
5
+ ## 0.41.0 - 2026-08-19
6
+
7
+ ### Added
8
+
9
+ - Add one parameterized, platform-neutral Mobile Perps performance recipe covering cold, cached, resume, reconnect, account-switch, and network-switch lifecycles with native visible-content proof and production loading/WebSocket evidence.
10
+
11
+ ### Changed
12
+
13
+ - Replace the duplicated Android, iOS, cold, warm, and background Perps performance recipes with the single lifecycle recipe.
14
+
15
+ ### Fixed
16
+
17
+ - Keep explicitly pinned iOS and Android bridge targets isolated when both platforms are active, and classify bounded CDP target failures as infrastructure failures.
18
+ - Make Mobile wallet unlock and account selection deterministic across app restarts, propagate recipe timeouts through Perps state setup, and preserve missing performance counts and offsets as unknown instead of zero.
19
+ - Keep the entire checkout-local `temp` tree outside Metro watching during canonical `yarn watch` launches, and leave the Mobile recipe HUD visible by default for performance lifecycles.
20
+
5
21
  ## 0.40.1 - 2026-08-16
6
22
 
7
23
  ### Fixed
@@ -42,6 +42,14 @@
42
42
  "inputs": "--target --port --log --pid-file [--workers] [--clear]",
43
43
  "outputs": "Metro PID/launch metadata and redirected log; exit 0/1/2"
44
44
  },
45
+ {
46
+ "id": "mobile/metro-config",
47
+ "entry": "adapters/mobile/metro-config.cjs",
48
+ "kind": "module",
49
+ "purpose": "Preserve the product Metro config while excluding the checkout-local temp tree owned by harness evidence and runtime state.",
50
+ "inputs": "base Metro config; env MM_HARNESS_METRO_PROJECT_ROOT, MM_HARNESS_METRO_BASE_CONFIG",
51
+ "outputs": "merged Metro config with resolver.blockList for <projectRoot>/temp"
52
+ },
45
53
  {
46
54
  "id": "mobile/stop-metro",
47
55
  "entry": "adapters/mobile/stop-metro.sh",
@@ -30,12 +30,13 @@ const {
30
30
  const { cdpEval, cdpEvalAsync } = require('./lib/cdp-eval.cjs');
31
31
  const { buildArmSnippet, buildCollectSnippet } = require('./lib/issue-capture.cjs');
32
32
 
33
- function parseHomepagePerformanceConsoleEvent(params) {
34
- const marker = '[HomepagePerf] ';
33
+ function parsePerformanceConsoleEvent(params) {
34
+ const markers = ['[PerpsPerf] ', '[HomepagePerf] '];
35
35
  for (const arg of params?.args || []) {
36
36
  const text = typeof arg?.value === 'string' ? arg.value : '';
37
+ const marker = markers.find((candidate) => text.includes(candidate));
38
+ if (!marker) continue;
37
39
  const markerIndex = text.indexOf(marker);
38
- if (markerIndex < 0) continue;
39
40
  try {
40
41
  return JSON.parse(text.slice(markerIndex + marker.length));
41
42
  } catch {}
@@ -490,7 +491,7 @@ const COMMANDS = {
490
491
  let removeConsoleListener = null;
491
492
  if (visibleEventStage) {
492
493
  removeConsoleListener = client.on('Runtime.consoleAPICalled', (params) => {
493
- const payload = parseHomepagePerformanceConsoleEvent(params);
494
+ const payload = parsePerformanceConsoleEvent(params);
494
495
  if (payload?.stage === visibleEventStage) visibleEvents.push(payload);
495
496
  });
496
497
  await client.send('Runtime.enable');
@@ -145,6 +145,9 @@ async function discoverTarget(port) {
145
145
  const adbSerial = process.env.ADB_SERIAL || process.env.ANDROID_SERIAL || '';
146
146
  const androidPinned = Boolean(androidTargetName || androidDevice);
147
147
  const simName = loadSimulatorName();
148
+ const iosPinned =
149
+ process.env.MM_HARNESS_EXPLICIT_PLATFORM === 'ios' ||
150
+ Boolean(process.env.SIM_UDID);
148
151
  const matchesAndroidPin = (target) => androidTargetName
149
152
  ? target.deviceName !== simName &&
150
153
  (target.deviceName === androidTargetName ||
@@ -166,6 +169,18 @@ async function discoverTarget(port) {
166
169
  }
167
170
  return false;
168
171
  }
172
+ if (iosPinned && simName) {
173
+ const pinnedCandidates = runtimeCandidates.filter(
174
+ (candidate) => candidate.deviceName === simName,
175
+ );
176
+ for (const candidate of pinnedCandidates) {
177
+ if (await probeTarget(candidate.webSocketDebuggerUrl)) {
178
+ acceptedPinnedCandidate = candidate;
179
+ return true;
180
+ }
181
+ }
182
+ return false;
183
+ }
169
184
  return true;
170
185
  });
171
186
  } catch (e) {
@@ -193,12 +208,16 @@ async function discoverTarget(port) {
193
208
  // context, and letting it win here sends a pinned android action to the iOS
194
209
  // target. No-match keeps the full candidate set (ambient sim configs tolerate a
195
210
  // sim that is not currently attached).
196
- if (simName && !androidPinned && candidates.length > 1) {
211
+ if (simName && !androidPinned) {
197
212
  const deviceFiltered = candidates.filter(
198
213
  (t) => t.deviceName === simName,
199
214
  );
200
215
  if (deviceFiltered.length > 0) {
201
216
  candidates = deviceFiltered;
217
+ } else if (iosPinned) {
218
+ throw coded(new Error(
219
+ `Pinned iOS simulator '${simName}' did not match any Metro target.`,
220
+ ), BRIDGE_ERROR_CODES.NO_TARGET);
202
221
  }
203
222
  }
204
223
 
@@ -306,15 +325,15 @@ async function discoverTarget(port) {
306
325
  }
307
326
  }
308
327
 
309
- if (androidPinned) {
328
+ if (androidPinned || iosPinned) {
310
329
  const candidateList = candidates
311
330
  .map((target) => ` deviceName=${JSON.stringify(target.deviceName || '')} ws=${target.webSocketDebuggerUrl || ''}`)
312
331
  .join('\n');
313
332
  throw coded(new Error(
314
- `Pinned Android device has no responding __AGENTIC__ JS runtime after ${FETCH_RETRIES} discovery attempt(s).\n` +
315
- ` Requested --device (ADB_SERIAL): ${adbSerial || '(not set)'}\n` +
333
+ `Pinned ${androidPinned ? 'Android device' : `iOS simulator '${simName || process.env.SIM_UDID || '(unknown)'}'`} has no responding __AGENTIC__ JS runtime after ${FETCH_RETRIES} discovery attempt(s).\n` +
334
+ (androidPinned ? ` Requested --device (ADB_SERIAL): ${adbSerial || '(not set)'}\n` : '') +
316
335
  ` Matching non-agentic targets:\n${candidateList}\n` +
317
- ` The native C++ page is not a valid recipe target; wait for Android Hermes to re-register or relaunch the Android app.`,
336
+ ` The native C++ page is not a valid recipe target; wait for Hermes to re-register or relaunch the pinned app.`,
318
337
  ), BRIDGE_ERROR_CODES.NO_TARGET);
319
338
  }
320
339
 
@@ -0,0 +1,94 @@
1
+ 'use strict';
2
+
3
+ const fs = require('node:fs');
4
+ const { createRequire } = require('node:module');
5
+ const path = require('node:path');
6
+ const { pathToFileURL } = require('node:url');
7
+
8
+ function escapeRegExp(value) {
9
+ return value.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
10
+ }
11
+
12
+ function appendBlockList(blockList, pattern) {
13
+ if (Array.isArray(blockList)) return [...blockList, pattern];
14
+ return blockList instanceof RegExp ? [blockList, pattern] : [pattern];
15
+ }
16
+
17
+ function blockListFlags(blockList) {
18
+ const first = Array.isArray(blockList) ? blockList[0] : blockList;
19
+ return first instanceof RegExp && first.ignoreCase ? 'i' : '';
20
+ }
21
+
22
+ function resolveExpoConfigLoader(projectRoot) {
23
+ const requireFromProject = createRequire(path.join(projectRoot, 'package.json'));
24
+ let requireUtilsPath;
25
+ try {
26
+ requireUtilsPath = requireFromProject.resolve('@expo/require-utils');
27
+ } catch (error) {
28
+ if (error?.code !== 'MODULE_NOT_FOUND') throw error;
29
+ return null;
30
+ }
31
+ const { loadModuleSync } = requireFromProject(requireUtilsPath);
32
+ return typeof loadModuleSync === 'function' ? loadModuleSync : null;
33
+ }
34
+
35
+ async function loadProductConfig(configPath, baseConfig, projectRoot) {
36
+ let loaded;
37
+ const expoConfigLoader = resolveExpoConfigLoader(projectRoot);
38
+ if (expoConfigLoader) {
39
+ loaded = expoConfigLoader(configPath);
40
+ } else {
41
+ try {
42
+ loaded = require(configPath);
43
+ } catch (error) {
44
+ if (error?.code !== 'ERR_REQUIRE_ESM') throw error;
45
+ loaded = await import(pathToFileURL(configPath).href);
46
+ }
47
+ }
48
+
49
+ const moduleValue = await loaded;
50
+ const exported =
51
+ moduleValue?.__esModule || moduleValue?.[Symbol.toStringTag] === 'Module'
52
+ ? moduleValue.default
53
+ : moduleValue;
54
+ const config = await exported;
55
+ return typeof config === 'function' ? config(baseConfig) : config;
56
+ }
57
+
58
+ module.exports = async function harnessMetroConfig(baseConfig) {
59
+ const projectRoot = path.resolve(
60
+ process.env.MM_HARNESS_METRO_PROJECT_ROOT || process.cwd(),
61
+ );
62
+ const productConfigPath = path.resolve(
63
+ process.env.MM_HARNESS_METRO_BASE_CONFIG ||
64
+ path.join(projectRoot, 'metro.config.js'),
65
+ );
66
+ if (productConfigPath === __filename) {
67
+ throw new Error(
68
+ 'MM_HARNESS_METRO_BASE_CONFIG must reference the product Metro config, not the harness wrapper.',
69
+ );
70
+ }
71
+ let productConfig = baseConfig;
72
+ if (fs.existsSync(productConfigPath)) {
73
+ productConfig = await loadProductConfig(
74
+ productConfigPath,
75
+ baseConfig,
76
+ projectRoot,
77
+ );
78
+ }
79
+
80
+ const tempRoot = path.join(projectRoot, 'temp');
81
+ const existingBlockList =
82
+ productConfig.resolver?.blockList ?? baseConfig.resolver?.blockList;
83
+ const tempPattern = new RegExp(
84
+ `^${escapeRegExp(tempRoot)}(?:${escapeRegExp(path.sep)}|$)`,
85
+ blockListFlags(existingBlockList),
86
+ );
87
+ return {
88
+ ...productConfig,
89
+ resolver: {
90
+ ...productConfig.resolver,
91
+ blockList: appendBlockList(existingBlockList, tempPattern),
92
+ },
93
+ };
94
+ };
@@ -50,7 +50,16 @@ fi
50
50
  # only ever inspect or signal a Metro listening on $PORT.
51
51
  # shellcheck disable=SC1091
52
52
  . "$SCRIPT_DIR/lib/metro-listener.sh"
53
- MOBILE_METRO_REQUIRED_ENV="${MOBILE_METRO_REQUIRED_ENV:-MM_INFURA_PROJECT_ID METAMASK_BUILD_TYPE METAMASK_ENVIRONMENT}"
53
+ HARNESS_METRO_CONFIG="$SCRIPT_DIR/metro-config.cjs"
54
+ if [ "${EXPO_OVERRIDE_METRO_CONFIG:-}" = "$HARNESS_METRO_CONFIG" ]; then
55
+ PRODUCT_METRO_CONFIG="${MM_HARNESS_METRO_BASE_CONFIG:-$TARGET/metro.config.js}"
56
+ else
57
+ PRODUCT_METRO_CONFIG="${EXPO_OVERRIDE_METRO_CONFIG:-$TARGET/metro.config.js}"
58
+ fi
59
+ export MM_HARNESS_METRO_PROJECT_ROOT="$TARGET"
60
+ export MM_HARNESS_METRO_BASE_CONFIG="$PRODUCT_METRO_CONFIG"
61
+ export EXPO_OVERRIDE_METRO_CONFIG="$HARNESS_METRO_CONFIG"
62
+ MOBILE_METRO_REQUIRED_ENV="${MOBILE_METRO_REQUIRED_ENV:-MM_INFURA_PROJECT_ID METAMASK_BUILD_TYPE METAMASK_ENVIRONMENT} MM_HARNESS_METRO_PROJECT_ROOT MM_HARNESS_METRO_BASE_CONFIG EXPO_OVERRIDE_METRO_CONFIG"
54
63
 
55
64
  # RECIPE_RUNTIME_DIR (relative, validated) makes runtime state per-run so jobs
56
65
  # sharing one checkout do not collide on metro.log / metro.pid / metro.tmux.
@@ -88,6 +97,9 @@ write_metro_build_env() {
88
97
  done
89
98
  )" || { rm -f "$temporary"; return 1; }
90
99
  eval "$product_values"
100
+ required_MM_HARNESS_METRO_PROJECT_ROOT="$MM_HARNESS_METRO_PROJECT_ROOT"
101
+ required_MM_HARNESS_METRO_BASE_CONFIG="$MM_HARNESS_METRO_BASE_CONFIG"
102
+ required_EXPO_OVERRIDE_METRO_CONFIG="$EXPO_OVERRIDE_METRO_CONFIG"
91
103
  [ "$CLEAR" = true ] && clear_value=1
92
104
  {
93
105
  printf 'export MM_HARNESS_METRO_WATCHER_PORT=%q\n' "$PORT"
@@ -26,6 +26,7 @@ function resolveAndroidModel(serial) {
26
26
  }
27
27
  function setAndroidDeviceEnv(id, fallbackName) {
28
28
  process.env.PLATFORM = "android";
29
+ process.env.MM_HARNESS_EXPLICIT_PLATFORM = "android";
29
30
  process.env.ADB_SERIAL = id;
30
31
  process.env.ANDROID_SERIAL = id;
31
32
  process.env.ANDROID_DEVICE = id;
@@ -33,9 +34,11 @@ function setAndroidDeviceEnv(id, fallbackName) {
33
34
  if (model) process.env.ANDROID_TARGET_DEVICE_NAME = model;
34
35
  else delete process.env.ANDROID_TARGET_DEVICE_NAME;
35
36
  process.env.IOS_SIMULATOR = "";
37
+ process.env.SIM_UDID = "";
36
38
  }
37
39
  function setIosDeviceEnv(id, fallbackName) {
38
40
  process.env.PLATFORM = "ios";
41
+ process.env.MM_HARNESS_EXPLICIT_PLATFORM = "ios";
39
42
  process.env.IOS_SIMULATOR = fallbackName?.trim() || id;
40
43
  process.env.SIM_UDID = id;
41
44
  process.env.ADB_SERIAL = "";
@@ -155,12 +155,11 @@ async function resolveRecipeExecution(adapter, recipe, artifactsDir, projectRoot
155
155
  projectRoot,
156
156
  rootRecipe: recipeDocument
157
157
  });
158
- const suppressHudForLifecycleTiming = adapter === "mobile" && mobileRecipeRequiresUninterruptedLifecycleTiming(recipeDocument);
159
158
  const suppressHudForOpaqueMobile = adapter === "mobile" && process.env.METAMASK_RECIPE_MOBILE_OPAQUE_RUNTIME === "1";
160
159
  const runnerOptions = {
161
160
  quietStdout: runtimeOptions.stdoutIsMachineContract === true,
162
161
  suppressLibraryResolutionLogs: runtimeOptions.suppressLibraryResolutionLogs,
163
- autoHud: suppressHudForLifecycleTiming || suppressHudForOpaqueMobile ? false : trust.source && trust.source.trust !== "trusted" ? false : runtimeOptions.autoHud,
162
+ autoHud: suppressHudForOpaqueMobile ? false : trust.source && trust.source.trust !== "trusted" ? false : runtimeOptions.autoHud,
164
163
  onActionEvent: runtimeOptions.onActionEvent,
165
164
  actionSources,
166
165
  // A direct engineer invocation is the explicit trust boundary for a
@@ -213,16 +212,6 @@ function runtimeRecipeDocument(recipe, absoluteRecipePath) {
213
212
  return void 0;
214
213
  }
215
214
  }
216
- function mobileRecipeRequiresUninterruptedLifecycleTiming(recipe) {
217
- if (!isRecord(recipe)) return false;
218
- const workflow = isRecord(recipe.workflow) ? recipe.workflow : void 0;
219
- const nodes = workflow && isRecord(workflow.nodes) ? workflow.nodes : void 0;
220
- if (!nodes) return false;
221
- const actions = new Set(
222
- Object.values(nodes).filter(isRecord).map((node) => node.action).filter((action) => typeof action === "string")
223
- );
224
- return actions.has("app.lifecycle") && actions.has("metamask.perps.capture_performance");
225
- }
226
215
  function recipeProcessEnvironment() {
227
216
  const env = { ...process.env };
228
217
  delete env.MM_HARNESS_CHECKOUT_LOCK_TOKEN;
@@ -314,7 +303,8 @@ function recipeRunEnv(adapter, runtimeOptions = {}) {
314
303
  RECIPE_CDP_PORT: runtimeOptions.cdpPort ?? process.env.RECIPE_CDP_PORT,
315
304
  FARMSLOT_SLOT_ID: runtimeOptions.slot ?? process.env.FARMSLOT_SLOT_ID,
316
305
  SLOT_ID: runtimeOptions.slot ?? process.env.SLOT_ID,
317
- PLATFORM: process.env.PLATFORM
306
+ PLATFORM: process.env.PLATFORM,
307
+ MM_HARNESS_EXPLICIT_PLATFORM: process.env.MM_HARNESS_EXPLICIT_PLATFORM
318
308
  };
319
309
  if (adapter !== "mobile") return base;
320
310
  return {
@@ -322,6 +312,7 @@ function recipeRunEnv(adapter, runtimeOptions = {}) {
322
312
  WATCHER_PORT: process.env.WATCHER_PORT ?? base.CDP_PORT ?? base.RECIPE_CDP_PORT,
323
313
  METRO_PORT: process.env.METRO_PORT ?? process.env.WATCHER_PORT ?? base.CDP_PORT ?? base.RECIPE_CDP_PORT,
324
314
  IOS_SIMULATOR: process.env.IOS_SIMULATOR,
315
+ SIM_UDID: process.env.SIM_UDID,
325
316
  ANDROID_DEVICE: process.env.ANDROID_DEVICE,
326
317
  ANDROID_TARGET_DEVICE_NAME: process.env.ANDROID_TARGET_DEVICE_NAME,
327
318
  ADB_SERIAL: process.env.ADB_SERIAL,
@@ -1378,7 +1369,6 @@ export {
1378
1369
  ensureMobileProofRuntime,
1379
1370
  executeWithHealBounds,
1380
1371
  listRunnableRecipes,
1381
- mobileRecipeRequiresUninterruptedLifecycleTiming,
1382
1372
  preflightRecipe,
1383
1373
  prepareHeal,
1384
1374
  prepareRuntimeIfNeeded,
@@ -62,6 +62,7 @@ async function ensureOverlay(adapter, target, heal, state, json) {
62
62
  }
63
63
  function classifyFailure(output) {
64
64
  if (/SCREENSHOT_PROTECTED|screenshot is protected by FLAG_SECURE/iu.test(output)) return "screenshot-protected";
65
+ if (/CDP_TIMEOUT|Mobile CDP bridge command timed out|NO_TARGET|WS_CLOSED/iu.test(output)) return "infra";
65
66
  if (/wallet|fixture|keyring|not seeded|\bsrp\b|password|onboard/iu.test(output)) return "wallet";
66
67
  if (/metro|cdp|chrome|bridge|bundle|packager|port\b|econnrefused|not reachable|watcher|dev client|websocket/iu.test(output)) {
67
68
  return "infra";
@@ -359,7 +359,8 @@ function liveAdapterProcessTimeoutMs(node) {
359
359
  const actionTimeouts = [
360
360
  node.target_timeout_ms,
361
361
  node.unlock_timeout_ms,
362
- node.timeout_ms
362
+ node.timeout_ms,
363
+ node.wait_timeout_ms
363
364
  ].map(Number).filter((value) => Number.isFinite(value) && value > 0);
364
365
  if (actionTimeouts.length > 0) {
365
366
  return Math.max(
@@ -243,7 +243,7 @@ Common overrides:
243
243
  --json Machine-readable output
244
244
  --json-stream Line-flushed JSONL progress + terminal event
245
245
  --record-video=full-run Record a video of the run
246
- --hud <auto|show|hide> Recipe HUD policy (default: auto)
246
+ --hud <auto|show|hide> Recipe HUD policy (auto shows normal Mobile recipes; hide is explicit)
247
247
 
248
248
  Advanced integrations:
249
249
  runtime: --cdp-port, --watcher-port/--metro-port, --runtime-dir, --slot,
package/docs/RECIPES.md CHANGED
@@ -176,30 +176,36 @@ For visual PR proof, prefer a short simulator recording or GIF over still
176
176
  screenshots when the claim is a UI flow (timing / transitions). Use screenshots
177
177
  as supporting stills.
178
178
 
179
- ### Perps Homepage visible performance
179
+ ### Perps loading lifecycle validation
180
180
 
181
- The bundled `perps.performance.homepage.*` family preserves the device-proven
182
- Homepage TTC/DFD journeys. Run every recipe with an explicit device pin,
183
- healing disabled, and full-run video:
181
+ One platform-neutral recipe owns the reusable Perps lifecycle proof. The
182
+ harness adapter supplies Android or iOS behavior; the graph and lifecycle
183
+ parameters remain identical. Run it with an explicit device pin:
184
184
 
185
185
  ```bash
186
- mm-harness run perps.performance.homepage.android-background-short --device <serial> --heal off --record-video=full-run
187
- mm-harness run perps.performance.homepage.android-background-reconnect --device <serial> --heal off --record-video=full-run
188
- mm-harness run perps.performance.homepage.android-cold-no-cache --device <serial> --heal off --record-video=full-run
189
- mm-harness run perps.performance.homepage.android-cold-disk-cache --device <serial> --heal off --record-video=full-run
190
- mm-harness run perps.performance.homepage.android-network-recovery --device <serial> --heal off --record-video=full-run
191
- mm-harness run perps.performance.homepage.ios-background-short --device <udid> --heal off --record-video=full-run
192
- mm-harness run perps.performance.homepage.ios-background-reconnect --device <udid> --heal off --record-video=full-run
193
- mm-harness run perps.performance.homepage.ios-cold-no-cache --device <udid> --heal off --record-video=full-run
194
- mm-harness run perps.performance.homepage.ios-cold-disk-cache --device <udid> --heal off --record-video=full-run
186
+ mm-harness run perps.performance \
187
+ --device <android-serial-or-ios-udid> \
188
+ account=<fixture-account-name> \
189
+ content_variant=trending \
190
+ market=BTC \
191
+ lifecycle=cold_no_cache \
192
+ --heal off --record-video=full-run
195
193
  ```
196
194
 
197
- The Android variants use a deterministic Hyperliquid testnet order and cancel
198
- it after capture. The iOS variants use the existing fixture wallet and validate
199
- trending content without fabricating position/order state. `app.network` is
200
- Android-only because host-wide iOS network mutation cannot isolate one
201
- simulator safely. These recipes validate viewport-demand-to-visible TTC/DFD;
202
- they do not relabel process-start duration as TTC.
195
+ The Mobile recipe HUD is shown by default, including during performance
196
+ validation. Use `--hud hide` only when a specific opaque/release proof requires
197
+ an unobstructed product surface; do not hide it merely to collect timings.
198
+
199
+ Supported lifecycle values are `navigate_return`, `cold_disk_cache`,
200
+ `cold_no_cache`, `background_short`, `background_reconnect`, `account_switch`,
201
+ and `network_switch`. The shared graph uses an existing fixture position and
202
+ never fabricates exchange state inside the measurement. It proves native
203
+ visible content with `ui.wait_for`, captures production `[PerpsLoadProof]` and
204
+ WebSocket milestones, and leaves detailed TTC/DFD to the app's Sentry traces.
205
+ It never relabels Metro, fixture, or process-start duration as TTC.
206
+ The recipe does not label a checkout as main or candidate; pin and record the
207
+ exact checkout commit externally, then use captured source milestones to verify
208
+ which data path actually ran.
203
209
 
204
210
  Rules:
205
211
 
@@ -1,4 +1,4 @@
1
1
  import { runAdapter } from '../platform/bridge.mjs';
2
- import { captureHomepagePerformance } from './performance-capture.mjs';
2
+ import { capturePerpsPerformance } from './performance-capture.mjs';
3
3
 
4
- runAdapter(captureHomepagePerformance);
4
+ runAdapter(capturePerpsPerformance);