@deeeed/metamask-harness 0.40.0 → 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 (39) hide show
  1. package/CHANGELOG.md +22 -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/funding-execution-context.js +35 -28
  10. package/dist/heal-bounds.js +1 -0
  11. package/dist/live-adapter-contract.js +2 -1
  12. package/dist/mm-harness-cli.js +1 -1
  13. package/docs/RECIPES.md +25 -19
  14. package/library/actions/extension/perps/mutation-receipt.mjs +12 -8
  15. package/library/actions/extension/perps/select_activity_filter.mjs +1 -1
  16. package/library/actions/extension/perps/select_market_filter.mjs +1 -1
  17. package/library/actions/mobile/perps/capture_performance.mjs +2 -2
  18. package/library/actions/mobile/perps/performance-capture.mjs +375 -17
  19. package/library/actions/mobile/perps/perps.mjs +56 -10
  20. package/library/actions/mobile/platform/bridge.mjs +59 -1
  21. package/library/actions/mobile/wallet/ensure_unlocked.mjs +60 -26
  22. package/library/actions/mobile/wallet/import.mjs +2 -0
  23. package/library/actions/mobile/wallet/select_account.mjs +21 -2
  24. package/library/manifests/mobile.action-manifest.json +47 -6
  25. package/library/recipes/mobile/perps/performance.recipe.json +680 -26
  26. package/package.json +1 -1
  27. package/library/recipes/mobile/perps/performance.background-resume.recipe.json +0 -56
  28. package/library/recipes/mobile/perps/performance.cold-start.recipe.json +0 -56
  29. package/library/recipes/mobile/perps/performance.homepage.android-background-reconnect.recipe.json +0 -159
  30. package/library/recipes/mobile/perps/performance.homepage.android-background-short.recipe.json +0 -157
  31. package/library/recipes/mobile/perps/performance.homepage.android-cold-disk-cache.recipe.json +0 -165
  32. package/library/recipes/mobile/perps/performance.homepage.android-cold-no-cache.recipe.json +0 -139
  33. package/library/recipes/mobile/perps/performance.homepage.android-network-recovery.recipe.json +0 -149
  34. package/library/recipes/mobile/perps/performance.homepage.cold-position-sample.recipe.json +0 -120
  35. package/library/recipes/mobile/perps/performance.homepage.ios-background-reconnect.recipe.json +0 -111
  36. package/library/recipes/mobile/perps/performance.homepage.ios-background-short.recipe.json +0 -108
  37. package/library/recipes/mobile/perps/performance.homepage.ios-cold-disk-cache.recipe.json +0 -122
  38. package/library/recipes/mobile/perps/performance.homepage.ios-cold-no-cache.recipe.json +0 -95
  39. package/library/recipes/mobile/perps/performance.warm-start.recipe.json +0 -49
@@ -145,6 +145,7 @@ export async function bridgeEnv(input) {
145
145
  if (adbPath) env.MM_HARNESS_ADB_PATH = adbPath;
146
146
  if (idbPath) env.MM_HARNESS_IDB_PATH = idbPath;
147
147
  const target = resolveMobileTarget(input);
148
+ const requestedPlatform = target.requestedPlatform;
148
149
  const watcherPort = target.watcherPort;
149
150
  const simulator = target.iosSimulator;
150
151
  const androidDevice = target.androidDevice;
@@ -160,6 +161,12 @@ export async function bridgeEnv(input) {
160
161
  env.ADB_SERIAL = String(adbSerial);
161
162
  env.ANDROID_SERIAL = String(adbSerial);
162
163
  }
164
+ if (requestedPlatform === 'ios') {
165
+ env.ADB_SERIAL = '';
166
+ env.ANDROID_SERIAL = '';
167
+ env.ANDROID_DEVICE = '';
168
+ env.ANDROID_TARGET_DEVICE_NAME = '';
169
+ }
163
170
  // When --device passes an adb serial, device-target.ts sets both ANDROID_DEVICE and
164
171
  // ADB_SERIAL to the same value. ANDROID_DEVICE must be the Metro deviceName for
165
172
  // target-discovery to select the right CDP target; it cannot be a raw adb serial.
@@ -210,12 +217,55 @@ export function shouldResolveAndroidTargetModel(
210
217
 
211
218
  function resolveMobileTarget(input) {
212
219
  const contextEnv = input.context?.env || {};
220
+ const explicitSimulatorUdid =
221
+ input.node?.sim_udid ?? contextEnv.SIM_UDID ?? process.env.SIM_UDID;
222
+ const configuredPlatform = String(
223
+ input.node?.platform ??
224
+ contextEnv.MM_HARNESS_EXPLICIT_PLATFORM ??
225
+ process.env.MM_HARNESS_EXPLICIT_PLATFORM ??
226
+ contextEnv.PLATFORM ??
227
+ process.env.PLATFORM ??
228
+ '',
229
+ ).toLowerCase();
230
+ const requestedPlatform =
231
+ configuredPlatform === 'ios' || configuredPlatform === 'android'
232
+ ? configuredPlatform
233
+ : explicitSimulatorUdid
234
+ ? 'ios'
235
+ : configuredPlatform;
213
236
  const watcherPort = input.node?.watcher_port ?? input.node?.metro_port ?? input.node?.cdp_port ?? contextEnv.WATCHER_PORT ?? contextEnv.CDP_PORT ?? contextEnv.RECIPE_CDP_PORT ?? process.env.WATCHER_PORT ?? process.env.CDP_PORT ?? process.env.RECIPE_CDP_PORT;
214
237
  const iosSimulator = input.node?.simulator ?? input.node?.ios_simulator ?? contextEnv.IOS_SIMULATOR ?? process.env.IOS_SIMULATOR;
215
238
  const androidDevice = input.node?.android_device ?? contextEnv.ANDROID_DEVICE ?? process.env.ANDROID_DEVICE;
216
239
  const androidTargetDeviceName = input.node?.android_target_device_name ?? input.node?.androidTargetDeviceName ?? contextEnv.ANDROID_TARGET_DEVICE_NAME ?? process.env.ANDROID_TARGET_DEVICE_NAME;
217
240
  const adbSerial = input.node?.adb_serial ?? contextEnv.ADB_SERIAL ?? contextEnv.ANDROID_SERIAL ?? process.env.ADB_SERIAL ?? process.env.ANDROID_SERIAL ?? androidDevice;
218
- return { watcherPort, iosSimulator, androidDevice, androidTargetDeviceName, adbSerial };
241
+ if (requestedPlatform === 'ios') {
242
+ return {
243
+ watcherPort,
244
+ iosSimulator,
245
+ androidDevice: undefined,
246
+ androidTargetDeviceName: undefined,
247
+ adbSerial: undefined,
248
+ requestedPlatform,
249
+ };
250
+ }
251
+ if (requestedPlatform === 'android') {
252
+ return {
253
+ watcherPort,
254
+ iosSimulator: undefined,
255
+ androidDevice,
256
+ androidTargetDeviceName,
257
+ adbSerial,
258
+ requestedPlatform,
259
+ };
260
+ }
261
+ return {
262
+ watcherPort,
263
+ iosSimulator,
264
+ androidDevice,
265
+ androidTargetDeviceName,
266
+ adbSerial,
267
+ requestedPlatform,
268
+ };
219
269
  }
220
270
 
221
271
  export async function openExternalDeeplink(input, url, timeout = 15000) {
@@ -353,6 +403,14 @@ export function selectBridgeStatusEntry(status, input) {
353
403
  if (iosSimulator) {
354
404
  const sim = entries.find((entry) => entry.deviceName === iosSimulator);
355
405
  if (sim) return sim;
406
+ if (target.requestedPlatform === 'ios') {
407
+ throw coded(new Error(
408
+ `Pinned iOS simulator ${iosSimulator} has no responding bridge status entry.\n` +
409
+ ` Bridge status entries:\n` +
410
+ entries.map((entry) => ` - ${entry.deviceName ?? '?'} [${entry.platform || 'unknown'}]`).join('\n') +
411
+ `\n Next: mm-harness launch ios # bring the pinned app back to the foreground`,
412
+ ), BRIDGE_ERROR_CODES.NO_TARGET);
413
+ }
356
414
  }
357
415
  if (androidDevice) {
358
416
  const byName = entries.find(
@@ -56,7 +56,8 @@ async function statusBeforeDeadline(input, deadline) {
56
56
  30000,
57
57
  );
58
58
  const probeTimeoutMs = Math.min(
59
- 5000,
59
+ 15000,
60
+ Math.max(5000, Math.floor(remainingMs / 3)),
60
61
  remainingMs,
61
62
  Number.isFinite(configuredMs) && configuredMs > 0 ? configuredMs : 30000,
62
63
  );
@@ -153,7 +154,7 @@ async function waitForStableUnlocked(input, initialStatus, stableMs = 750) {
153
154
  if (transientDrops > 2) return null;
154
155
  }
155
156
  }
156
- return last;
157
+ return isUnlockedStatus(last, input) ? last : null;
157
158
  }
158
159
 
159
160
  export async function ensureUnlocked(input) {
@@ -188,37 +189,70 @@ export async function ensureUnlocked(input) {
188
189
  unlocked: true,
189
190
  alreadyUnlocked: true,
190
191
  account: selectedAccount(stableBefore, input),
192
+ deviceName: selectedStatus(stableBefore, input)?.deviceName ?? null,
193
+ platform: selectedStatus(stableBefore, input)?.platform ?? null,
191
194
  redacted: true,
192
195
  proofPath: 'agentic-wallet-status',
193
196
  };
194
197
  }
195
198
 
196
199
  const password = input.node?.password ?? await fixturePassword(input.context.projectRoot);
197
- try {
198
- const result = await bridgeCommand(input, ['unlock', String(password)]);
199
- const after = await waitForUnlocked(input, Number(input.node?.unlock_timeout_ms ?? 30000));
200
- const stableAfter = await waitForStableUnlocked(input, after, Number(input.node?.stable_unlocked_ms ?? 750)) ?? after;
201
- return {
202
- action: input.action,
203
- unlocked: Boolean(result?.ok ?? result?.unlocked ?? true),
204
- account: selectedAccount(stableAfter, input),
205
- route: selectedStatus(stableAfter, input)?.route ?? null,
206
- redacted: true,
207
- proofPath: 'agentic-wallet-unlock',
208
- };
209
- } catch (error) {
210
- if (!String(error?.message ?? error).includes('login-password-input not found')) throw error;
211
- const after = await status(input);
212
- if (!selectedAccount(after, input) || routeName(after, input) === 'Login') throw error;
213
- return {
214
- action: input.action,
215
- unlocked: true,
216
- alreadyUnlocked: true,
217
- account: selectedAccount(after, input),
218
- redacted: true,
219
- proofPath: 'agentic-wallet-status-after-missing-login-input',
220
- };
200
+ const unlockTimeoutMs = Number(input.node?.unlock_timeout_ms ?? 30000);
201
+ const unlockDeadline = Date.now() + unlockTimeoutMs;
202
+ let result;
203
+ while (result === undefined && Date.now() < unlockDeadline) {
204
+ try {
205
+ result = await bridgeCommand(input, ['unlock', String(password)]);
206
+ } catch (error) {
207
+ if (!String(error?.message ?? error).includes('login-password-input not found')) {
208
+ throw error;
209
+ }
210
+ try {
211
+ const current = await statusBeforeDeadline(input, unlockDeadline);
212
+ if (selectedAccount(current, input) && routeName(current, input) !== 'Login') {
213
+ return {
214
+ action: input.action,
215
+ unlocked: true,
216
+ alreadyUnlocked: true,
217
+ account: selectedAccount(current, input),
218
+ deviceName: selectedStatus(current, input)?.deviceName ?? null,
219
+ platform: selectedStatus(current, input)?.platform ?? null,
220
+ redacted: true,
221
+ proofPath: 'agentic-wallet-status-after-missing-login-input',
222
+ };
223
+ }
224
+ } catch {
225
+ // The bridge target can rotate while the login input mounts. Keep the
226
+ // retry bounded by the same unlock deadline.
227
+ }
228
+ await new Promise((resolve) => setTimeout(resolve, 250));
229
+ }
230
+ }
231
+ if (result === undefined) {
232
+ throw new Error(
233
+ `Timed out waiting ${unlockTimeoutMs}ms for the Mobile login password input.`,
234
+ );
235
+ }
236
+ const remainingUnlockMs = Math.max(1, unlockDeadline - Date.now());
237
+ const after = await waitForUnlocked(input, remainingUnlockMs);
238
+ const stableAfter = await waitForStableUnlocked(
239
+ input,
240
+ after,
241
+ Number(input.node?.stable_unlocked_ms ?? 750),
242
+ );
243
+ if (!stableAfter) {
244
+ throw new Error('Mobile wallet did not remain unlocked for the required stability window.');
221
245
  }
246
+ return {
247
+ action: input.action,
248
+ unlocked: Boolean(result?.ok ?? result?.unlocked ?? true),
249
+ account: selectedAccount(stableAfter, input),
250
+ route: selectedStatus(stableAfter, input)?.route ?? null,
251
+ deviceName: selectedStatus(stableAfter, input)?.deviceName ?? null,
252
+ platform: selectedStatus(stableAfter, input)?.platform ?? null,
253
+ redacted: true,
254
+ proofPath: 'agentic-wallet-unlock',
255
+ };
222
256
  }
223
257
 
224
258
  if (process.argv[1] && import.meta.url === pathToFileURL(process.argv[1]).href) {
@@ -319,6 +319,8 @@ runAdapter(async (input) => {
319
319
  ...input,
320
320
  node: {
321
321
  ...input.node,
322
+ target_timeout_ms: input.node?.target_timeout_ms ?? timeoutMs,
323
+ unlock_timeout_ms: input.node?.unlock_timeout_ms ?? timeoutMs,
322
324
  ...(route === 'Login' ? { password: credentials.password } : {}),
323
325
  },
324
326
  });
@@ -1,8 +1,9 @@
1
+ import { pathToFileURL } from 'node:url';
1
2
  import { bridgeCommand, runAdapter } from '../platform/bridge.mjs';
2
3
 
3
4
  const sleep = (ms) => new Promise((resolve) => setTimeout(resolve, ms));
4
5
 
5
- runAdapter(async (input) => {
6
+ export async function selectAccount(input) {
6
7
  const requestedAddress = input.node?.address ? String(input.node.address).toLowerCase() : null;
7
8
  const requestedId = input.node?.id ? String(input.node.id) : null;
8
9
  const requestedName = input.node?.name ? String(input.node.name) : null;
@@ -24,6 +25,20 @@ runAdapter(async (input) => {
24
25
  if (!match?.address) {
25
26
  throw new Error(`Requested mobile account was not found in list-accounts: ${JSON.stringify({ address: requestedAddress, id: requestedId, name: requestedName })}`);
26
27
  }
28
+ const current = await bridgeCommand(input, ['get-selected-account']);
29
+ const matchAddress = String(match.address).toLowerCase();
30
+ if (String(current?.address ?? current ?? '').toLowerCase() === matchAddress) {
31
+ return {
32
+ action: input.action,
33
+ selected: {
34
+ address: current?.address ?? match.address,
35
+ id: current?.id ?? match.id,
36
+ name: current?.name ?? match.name,
37
+ },
38
+ alreadySelected: true,
39
+ proofPath: 'agentic-account-selection',
40
+ };
41
+ }
27
42
  const result = await bridgeCommand(input, ['switch-account', String(match.address)]);
28
43
  const timeoutMs = Number(input.node?.timeout_ms ?? 5000);
29
44
  const deadline = Date.now() + timeoutMs;
@@ -49,4 +64,8 @@ runAdapter(async (input) => {
49
64
  },
50
65
  proofPath: 'agentic-account-selection',
51
66
  };
52
- });
67
+ }
68
+
69
+ if (process.argv[1] && import.meta.url === pathToFileURL(process.argv[1]).href) {
70
+ runAdapter(selectAccount);
71
+ }
@@ -1395,6 +1395,11 @@
1395
1395
  },
1396
1396
  "unlock_timeout_ms": {
1397
1397
  "type": "number"
1398
+ },
1399
+ "stable_unlocked_ms": {
1400
+ "type": "number",
1401
+ "minimum": 0,
1402
+ "description": "How long the unlocked route must remain stable before the action succeeds; zero disables the extra stability window."
1398
1403
  }
1399
1404
  },
1400
1405
  "additionalProperties": false
@@ -1445,6 +1450,11 @@
1445
1450
  "type": "number",
1446
1451
  "minimum": 1,
1447
1452
  "description": "Maximum time in milliseconds for the mobile CDP command."
1453
+ },
1454
+ "timeout_ms": {
1455
+ "type": "number",
1456
+ "minimum": 1,
1457
+ "description": "Maximum time in milliseconds to observe the requested account as selected."
1448
1458
  }
1449
1459
  },
1450
1460
  "additionalProperties": false
@@ -2877,7 +2887,7 @@
2877
2887
  "execution_capabilities": ["app-mutation", "external-mutation"]
2878
2888
  },
2879
2889
  "metamask.perps.capture_performance": {
2880
- "description": "Capture run-window [HomepagePerf] and optional [StartupPerf] DevLogger records with parsed Perps on-device validation summaries.",
2890
+ "description": "Capture a bounded app-console window, summarize production [PerpsLoadProof]/WebSocket milestones, and optionally parse detailed [PerpsPerf]/[StartupPerf] records when a build emits them.",
2881
2891
  "schema": {
2882
2892
  "type": "object",
2883
2893
  "properties": {
@@ -2891,6 +2901,24 @@
2891
2901
  "artifact_prefix": {
2892
2902
  "type": "string"
2893
2903
  },
2904
+ "wait_for_stage": {
2905
+ "type": "string",
2906
+ "description": "For phase=end, optionally poll until this detailed PerpsPerf stage exists before writing artifacts."
2907
+ },
2908
+ "wait_for_source": {
2909
+ "type": "string"
2910
+ },
2911
+ "wait_for_lifecycle": {
2912
+ "type": "string"
2913
+ },
2914
+ "wait_timeout_ms": {
2915
+ "type": "number",
2916
+ "minimum": 1
2917
+ },
2918
+ "wait_poll_interval_ms": {
2919
+ "type": "number",
2920
+ "minimum": 10
2921
+ },
2894
2922
  "require_records": {
2895
2923
  "type": "boolean"
2896
2924
  },
@@ -2906,6 +2934,18 @@
2906
2934
  "type": "string"
2907
2935
  }
2908
2936
  },
2937
+ "require_perps_bootstrap_start_before_demand": {
2938
+ "type": "boolean",
2939
+ "description": "Require the first Perps surface demand to occur after a captured PerpsLoadProof perps_bootstrap_start boundary."
2940
+ },
2941
+ "required_live_streams": {
2942
+ "type": "array",
2943
+ "description": "Require values_ready records sourced from fresh_socket for each named Perps stream.",
2944
+ "items": {
2945
+ "type": "string",
2946
+ "enum": ["prices", "positions", "orders", "account"]
2947
+ }
2948
+ },
2909
2949
  "required_lifecycles": {
2910
2950
  "type": "array",
2911
2951
  "items": {
@@ -2969,8 +3009,9 @@
2969
3009
  {
2970
3010
  "action": "metamask.perps.capture_performance",
2971
3011
  "phase": "end",
2972
- "required_stages": ["viewport_demand", "next_frame_checkpoint"],
2973
- "intent": "Preserve the Perps Homepage performance evidence emitted during this run",
3012
+ "require_records": false,
3013
+ "required_live_streams": ["positions", "orders", "account"],
3014
+ "intent": "Preserve production Perps loading and live-stream evidence emitted during this run",
2974
3015
  "next": "done"
2975
3016
  }
2976
3017
  ],
@@ -3094,9 +3135,9 @@
3094
3135
  }
3095
3136
  },
3096
3137
  "page": {
3097
- "description": "Initial Perps page target; omit it to use the profile or market default.",
3098
- "type": "string",
3099
- "enum": ["home", "perps", "perps_home", "market", "market_details"]
3138
+ "description": "Initial Perps page target; omit it to use the profile or market default, or set false without market/symbol to preserve the current route.",
3139
+ "type": ["string", "boolean"],
3140
+ "enum": [false, "home", "perps", "perps_home", "market", "market_details"]
3100
3141
  },
3101
3142
  "positions": {
3102
3143
  "type": ["object", "boolean"],