@deeeed/metamask-harness 0.38.1 → 0.40.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 (104) hide show
  1. package/CHANGELOG.md +58 -0
  2. package/adapters/extension/lib/playwright-cdp.cjs +17 -1
  3. package/adapters/extension/sidepanel-toggle.sh +135 -13
  4. package/dist/adapters/mobile/release-artifact.js +65 -0
  5. package/dist/adapters.js +27 -17
  6. package/dist/command-contract.js +10 -2
  7. package/dist/commands/call.js +7 -4
  8. package/dist/commands/launch/index.js +55 -3
  9. package/dist/commands/parse-args.js +6 -0
  10. package/dist/commands/run-engine.js +75 -39
  11. package/dist/funding-execution-context.js +1386 -0
  12. package/dist/live-adapter-contract.js +3 -1
  13. package/dist/metamask-action-validation.js +170 -3
  14. package/dist/mm-harness-cli.js +8 -2
  15. package/dist/recipe-security.js +5 -12
  16. package/dist/runner.js +102 -6
  17. package/docs/RECIPES.md +74 -1
  18. package/docs/RELEASE-QA-CAPABILITY-MAP.md +6 -5
  19. package/library/actions/extension/assets/open_details.mjs +30 -0
  20. package/library/actions/extension/assets/set_token_visibility.mjs +5 -0
  21. package/library/actions/extension/networks/add_chainlist.mjs +4 -0
  22. package/library/actions/extension/networks/add_custom.mjs +4 -0
  23. package/library/actions/extension/networks/custom_network.mjs +48 -1
  24. package/library/actions/extension/perps/assert_visible_consistency.mjs +366 -0
  25. package/library/actions/extension/perps/close_visible_position.mjs +299 -37
  26. package/library/actions/extension/perps/compare_provider_market.mjs +77 -0
  27. package/library/actions/extension/perps/edit_margin.mjs +285 -44
  28. package/library/actions/extension/perps/mutation-receipt.mjs +781 -0
  29. package/library/actions/extension/perps/perps.mjs +1029 -363
  30. package/library/actions/extension/perps/read_funds_confirmation.mjs +126 -13
  31. package/library/actions/extension/perps/read_snapshot.mjs +124 -3
  32. package/library/actions/extension/perps/read_visible_state.mjs +63 -5
  33. package/library/actions/extension/perps/search_markets.mjs +32 -0
  34. package/library/actions/extension/perps/select_activity_filter.mjs +81 -18
  35. package/library/actions/extension/perps/select_market_filter.mjs +48 -4
  36. package/library/actions/extension/perps/set_market_favorite.mjs +6 -1
  37. package/library/actions/extension/perps/update_position_tpsl.mjs +251 -69
  38. package/library/actions/extension/perps/visible-mutation-identity.mjs +164 -0
  39. package/library/actions/extension/platform/cdp.mjs +88 -9
  40. package/library/actions/extension/settings/set_basic_functionality.mjs +35 -7
  41. package/library/actions/extension/swap_bridge/read_visible_state.mjs +2 -6
  42. package/library/actions/extension/swap_bridge/set_amount.mjs +11 -2
  43. package/library/actions/extension/ui/navigate.mjs +45 -5
  44. package/library/actions/extension/wallet/import.mjs +70 -29
  45. package/library/actions/extension/wallet/lock.mjs +62 -5
  46. package/library/actions/extension/wallet/visible-session.mjs +218 -0
  47. package/library/actions/mobile/assets/import_custom_token.mjs +181 -0
  48. package/library/actions/mobile/assets/open_details.mjs +43 -0
  49. package/library/actions/mobile/assets/read_details.mjs +96 -0
  50. package/library/actions/mobile/assets/set_token_visibility.mjs +302 -0
  51. package/library/actions/mobile/assets/verify_sorting.mjs +104 -0
  52. package/library/actions/mobile/networks/add_custom.mjs +125 -0
  53. package/library/actions/mobile/networks/network-management.mjs +132 -0
  54. package/library/actions/mobile/networks/read_visible_state.mjs +43 -0
  55. package/library/actions/mobile/networks/remove_custom.mjs +100 -0
  56. package/library/actions/mobile/perps/search_markets.mjs +46 -0
  57. package/library/actions/mobile/perps/set_market_favorite.mjs +117 -0
  58. package/library/actions/mobile/platform/native-session-name.mjs +14 -0
  59. package/library/actions/mobile/platform/native-session.mjs +59 -0
  60. package/library/actions/mobile/platform/observe-ui.mjs +9 -1
  61. package/library/actions/mobile/swap_bridge/native-session.mjs +10 -45
  62. package/library/actions/mobile/swap_bridge/select_assets.mjs +15 -1
  63. package/library/actions/mobile/swap_bridge/set_amount.mjs +1 -1
  64. package/library/actions/mobile/swap_bridge/set_max_amount.mjs +55 -0
  65. package/library/actions/mobile/swap_bridge/set_slippage.mjs +96 -0
  66. package/library/actions/mobile/swap_bridge/submit_transaction.mjs +141 -0
  67. package/library/actions/mobile/ui/native-navigation.mjs +120 -23
  68. package/library/actions/mobile/ui/navigate.mjs +13 -2
  69. package/library/actions/mobile/wallet/import.mjs +17 -10
  70. package/library/actions/mobile/wallet/lock.mjs +14 -0
  71. package/library/actions/mobile/wallet/native-ui.mjs +88 -15
  72. package/library/actions/shared/perps/visible-state.mjs +33 -11
  73. package/library/actions/shared/swap-bridge/visible-state.mjs +15 -0
  74. package/library/manifests/extension.action-manifest.json +1707 -1241
  75. package/library/manifests/mobile.action-manifest.json +308 -1
  76. package/library/recipes/assets/release-token-details.recipe.json +5 -4
  77. package/library/recipes/assets/release-token-hide-readd.recipe.json +2 -0
  78. package/library/recipes/{assets → extension/assets}/release-custom-network-token-persistence.recipe.json +1 -0
  79. package/library/recipes/{perps → extension/perps}/release-activity-filters.recipe.json +5 -5
  80. package/library/recipes/{perps → extension/perps}/release-funds-flows.recipe.json +3 -3
  81. package/library/recipes/extension/perps/release-live-limit-order.recipe.json +24 -19
  82. package/library/recipes/extension/perps/release-market-filters.recipe.json +97 -0
  83. package/library/recipes/extension/perps/release-order-entry.recipe.json +4 -1
  84. package/library/recipes/extension/perps/release-order-validation.recipe.json +53 -14
  85. package/library/recipes/extension/perps/release-visible-consistency.recipe.json +47 -0
  86. package/library/recipes/extension/perps/source-dev-snapshot-consistency.recipe.json +76 -0
  87. package/library/recipes/mobile/networks/release-custom-network-persistence.recipe.json +90 -0
  88. package/library/recipes/mobile/networks/release-multichain-visibility.recipe.json +58 -0
  89. package/library/recipes/perps/release-live-market-order.recipe.json +30 -13
  90. package/library/recipes/perps/release-market-details.recipe.json +1 -1
  91. package/library/recipes/perps/release-market-search.recipe.json +8 -4
  92. package/library/recipes/perps/release-watchlist.recipe.json +31 -19
  93. package/library/recipes/swap-bridge/release-custom-slippage.recipe.json +5 -13
  94. package/library/recipes/swap-bridge/release-quote.recipe.json +1 -0
  95. package/library/recipes/wallet/import.recipe.json +4 -14
  96. package/package.json +1 -1
  97. package/library/actions/extension/assets/finish_send.mjs +0 -128
  98. package/library/actions/extension/swap_bridge/submit_transaction.mjs +0 -363
  99. package/library/actions/extension/ui/activate_and_observe.mjs +0 -96
  100. package/library/recipes/assets/release-custom-gas-send.recipe.json +0 -88
  101. package/library/recipes/assets/release-native-max-cancel.recipe.json +0 -70
  102. package/library/recipes/assets/release-native-max-send.recipe.json +0 -78
  103. package/library/recipes/assets/release-own-account-send.recipe.json +0 -80
  104. package/library/recipes/extension/runner/action-validation.recipe.json +0 -343
@@ -1,6 +1,9 @@
1
1
  import { createAgentDeviceUiTransport } from '@farmslot/expo-recipe';
2
2
  import { createAgentDeviceClient } from 'agent-device';
3
- import { nativeSessionName } from '../platform/native-session-name.mjs';
3
+ import {
4
+ nativeAgentDeviceStateDir,
5
+ nativeSessionName,
6
+ } from '../platform/native-session-name.mjs';
4
7
 
5
8
  const TEST_IDS = {
6
9
  existingWallet: 'wallet-setup-screen-have-an-existing-wallet-button-id',
@@ -30,6 +33,8 @@ const TEST_IDS = {
30
33
  perpsHomeBack: 'perps-home-back-button',
31
34
  perpsMarket: 'perps-market-details-view',
32
35
  perpsMarketBack: 'perps-market-header-back-button',
36
+ perpsProMarket: 'perps-pro-market-scroll-view',
37
+ perpsProMarketBack: 'perps-pro-market-header-back-button',
33
38
  hamburger: 'navbar-hamburger-menu-button',
34
39
  lock: 'lock-menu-item',
35
40
  confirmLock: 'android:id/button1',
@@ -85,6 +90,10 @@ function isWalletScreen(nodes) {
85
90
  return nodes.some((node) => WALLET_SCREEN_IDS.includes(String(node.identifier ?? '')));
86
91
  }
87
92
 
93
+ function visibleSnapshotNodes(nodes) {
94
+ return nodes.filter((node) => node.visibleToUser !== false);
95
+ }
96
+
88
97
  function hasBlockingSystemUi(nodes) {
89
98
  return nodes.some((node) =>
90
99
  (node.bundleId === 'com.android.systemui' || PERMISSION_CONTROLLER_PACKAGES.has(node.bundleId)) &&
@@ -101,7 +110,7 @@ export async function importWalletThroughNativeUi(input, credentials, options) {
101
110
  const native = nativeSession(input);
102
111
  try {
103
112
  const initial = await native.normalizeWalletSurface(options.timeoutMs);
104
- if (initial.wallet) {
113
+ if (initial.wallet && !initial.visible.has(TEST_IDS.walletTourSkip)) {
105
114
  if (options.requireFresh) {
106
115
  throw new Error('metamask.wallet.import method=ui requires a fresh Mobile onboarding state.');
107
116
  }
@@ -183,6 +192,27 @@ export async function unlockWalletThroughNativeUi(input, password, timeoutMs) {
183
192
  }
184
193
  }
185
194
 
195
+ export async function lockWalletThroughNativeUi(input) {
196
+ const native = nativeSession(input);
197
+ const timeoutMs = Number(input.node?.timeout_ms ?? 30_000);
198
+ try {
199
+ const state = await native.normalizeWalletSurface(timeoutMs);
200
+ if (state.visible.has(TEST_IDS.login)) {
201
+ return { alreadyLocked: true, provider: 'android-native-accessibility' };
202
+ }
203
+ if (!state.wallet) throw new Error('Opaque Mobile wallet lock could not reach wallet home.');
204
+ await native.press(TEST_IDS.hamburger, timeoutMs);
205
+ await native.press(TEST_IDS.lock, timeoutMs);
206
+ if (await native.exists(TEST_IDS.confirmLock, 3000)) {
207
+ await native.press(TEST_IDS.confirmLock, timeoutMs);
208
+ }
209
+ await native.wait(TEST_IDS.login, timeoutMs);
210
+ return { alreadyLocked: false, provider: 'android-native-accessibility' };
211
+ } finally {
212
+ await native.close();
213
+ }
214
+ }
215
+
186
216
  export async function resetWalletThroughNativeUi(input) {
187
217
  const native = nativeSession(input);
188
218
  const timeoutMs = Number(input.node?.timeout_ms ?? 60_000);
@@ -219,8 +249,10 @@ function nativeSession(input) {
219
249
  if (!device) throw new Error('Opaque Mobile UI execution requires one explicit Android device serial.');
220
250
  const app = String(env.ANDROID_PACKAGE_ID ?? 'io.metamask');
221
251
  const session = nativeSessionName(env, device, app);
252
+ const stateDir = nativeAgentDeviceStateDir(env, input.context.projectRoot, device);
222
253
  const client = createAgentDeviceClient({
223
254
  session,
255
+ stateDir,
224
256
  lockPolicy: 'reject',
225
257
  lockPlatform: 'android',
226
258
  });
@@ -230,6 +262,7 @@ function nativeSession(input) {
230
262
  device,
231
263
  app,
232
264
  session,
265
+ stateDir,
233
266
  client,
234
267
  });
235
268
  const context = {
@@ -240,7 +273,11 @@ function nativeSession(input) {
240
273
  };
241
274
  const execute = (action, node) => transport.execute(action, node, context);
242
275
  return {
243
- press: (testId, timeoutMs) => execute('ui.press', { test_id: testId, timeout_ms: timeoutMs }),
276
+ press: (testId, timeoutMs) => execute('ui.press', {
277
+ test_id: testId,
278
+ timeout_ms: timeoutMs,
279
+ settle: false,
280
+ }),
244
281
  fill: (testId, value, timeoutMs) => execute('ui.set_input', {
245
282
  test_id: testId,
246
283
  value,
@@ -259,7 +296,7 @@ function nativeSession(input) {
259
296
  interactiveOnly: false,
260
297
  forceFull: true,
261
298
  });
262
- const actual = new Map(snapshot.nodes
299
+ const actual = new Map(visibleSnapshotNodes(snapshot.nodes)
263
300
  .filter((node) => /^phrase-input-id_\d+$/u.test(String(node.identifier ?? '')))
264
301
  .map((node) => [
265
302
  Number(String(node.identifier).split('_').at(-1)),
@@ -288,11 +325,12 @@ function nativeSession(input) {
288
325
  interactiveOnly: false,
289
326
  forceFull: true,
290
327
  });
328
+ const nodes = visibleSnapshotNodes(snapshot.nodes);
291
329
  return {
292
- nodes: snapshot.nodes,
293
- visible: new Set(snapshot.nodes.map((node) => String(node.identifier ?? ''))),
294
- wallet: isWalletScreen(snapshot.nodes),
295
- postPassword: isPostPasswordScreen(snapshot.nodes) || isMetaMaskBiometricPrompt(snapshot.nodes),
330
+ nodes,
331
+ visible: new Set(nodes.map((node) => String(node.identifier ?? ''))),
332
+ wallet: isWalletScreen(nodes),
333
+ postPassword: isPostPasswordScreen(nodes) || isMetaMaskBiometricPrompt(nodes),
296
334
  };
297
335
  },
298
336
  async normalizeWalletSurface(timeoutMs) {
@@ -307,7 +345,12 @@ function nativeSession(input) {
307
345
  ) {
308
346
  return state;
309
347
  }
310
- if (state.visible.has(TEST_IDS.perpsMarketBack)) {
348
+ if (
349
+ state.visible.has(TEST_IDS.perpsProMarket) &&
350
+ state.visible.has(TEST_IDS.perpsProMarketBack)
351
+ ) {
352
+ await this.press(TEST_IDS.perpsProMarketBack, timeoutMs);
353
+ } else if (state.visible.has(TEST_IDS.perpsMarketBack)) {
311
354
  await this.press(TEST_IDS.perpsMarketBack, timeoutMs);
312
355
  } else if (state.visible.has(TEST_IDS.perpsHomeBack)) {
313
356
  await this.press(TEST_IDS.perpsHomeBack, timeoutMs);
@@ -333,13 +376,15 @@ function nativeSession(input) {
333
376
  let walletTourSkipped = false;
334
377
  let interestsHandled = false;
335
378
  let awaitingNotificationPermission = false;
379
+ let stableWalletCaptures = 0;
336
380
  while (Date.now() <= deadline) {
337
381
  const snapshot = await client.capture.snapshot({
338
382
  ...selection,
339
383
  interactiveOnly: false,
340
384
  forceFull: true,
341
385
  });
342
- if (isMetaMaskBiometricPrompt(snapshot.nodes)) {
386
+ const nodes = visibleSnapshotNodes(snapshot.nodes);
387
+ if (isMetaMaskBiometricPrompt(nodes)) {
343
388
  if (options.biometricsEnabled) {
344
389
  throw new Error('Mobile biometrics=true requires manual device authentication and is not supported by an unattended recipe.');
345
390
  }
@@ -351,7 +396,7 @@ function nativeSession(input) {
351
396
  biometricPromptDismissed = true;
352
397
  continue;
353
398
  }
354
- if (isMetaMaskNotificationPermissionPrompt(snapshot.nodes) && awaitingNotificationPermission) {
399
+ if (isMetaMaskNotificationPermissionPrompt(nodes) && awaitingNotificationPermission) {
355
400
  await client.command.alert({
356
401
  ...selection,
357
402
  action: 'accept',
@@ -362,12 +407,21 @@ function nativeSession(input) {
362
407
  notificationsEnabled = true;
363
408
  continue;
364
409
  }
365
- if (hasBlockingSystemUi(snapshot.nodes)) {
410
+ if (hasBlockingSystemUi(nodes)) {
366
411
  throw new Error('Unexpected Android system UI blocked Mobile wallet creation; refusing to dismiss it as biometric authentication.');
367
412
  }
368
- const visible = new Set(snapshot.nodes.map((node) => String(node.identifier ?? '')));
413
+ const visible = new Set(nodes.map((node) => String(node.identifier ?? '')));
414
+ const walletVisible = WALLET_SCREEN_IDS.some((testId) => visible.has(testId));
415
+ if (!walletVisible) stableWalletCaptures = 0;
369
416
  if (visible.has(TEST_IDS.metricsContinue)) {
370
417
  if (!options.metametricsEnabled) {
418
+ if (!visible.has(TEST_IDS.metricsCheckbox)) {
419
+ await this.scroll('down', options.timeoutMs);
420
+ const afterScroll = await this.screenState();
421
+ if (!afterScroll.visible.has(TEST_IDS.metricsCheckbox)) {
422
+ throw new Error('Mobile MetaMetrics opt-out checkbox remained unavailable after one bounded onboarding scroll.');
423
+ }
424
+ }
371
425
  await this.press(TEST_IDS.metricsCheckbox, options.timeoutMs);
372
426
  }
373
427
  await this.press(TEST_IDS.metricsContinue, options.timeoutMs);
@@ -402,6 +456,7 @@ function nativeSession(input) {
402
456
  continue;
403
457
  }
404
458
  if (visible.has(TEST_IDS.walletTourSkip)) {
459
+ stableWalletCaptures = 0;
405
460
  if (options.skipWalletTour) {
406
461
  await this.press(TEST_IDS.walletTourSkip, options.timeoutMs);
407
462
  walletTourSkipped = true;
@@ -409,10 +464,15 @@ function nativeSession(input) {
409
464
  }
410
465
  throw new Error('Mobile wallet-home tour remains visible because skip_wallet_tour=false; wallet readiness cannot be proven.');
411
466
  }
412
- if (WALLET_SCREEN_IDS.some((testId) => visible.has(testId))) {
467
+ if (walletVisible) {
413
468
  if (options.interests.length > 0 && !interestsHandled) {
414
469
  throw new Error('Mobile onboarding interests were requested, but the questionnaire is not enabled for this build.');
415
470
  }
471
+ stableWalletCaptures += 1;
472
+ if (stableWalletCaptures < 2) {
473
+ await new Promise((resolve) => setTimeout(resolve, 500));
474
+ continue;
475
+ }
416
476
  return {
417
477
  biometricsEnabled: options.biometricsEnabled,
418
478
  biometricPromptDismissed,
@@ -431,10 +491,23 @@ function nativeSession(input) {
431
491
  timeout_ms: timeoutMs,
432
492
  settle: false,
433
493
  }),
494
+ scroll: (direction, timeoutMs) => execute('ui.scroll', {
495
+ direction,
496
+ amount: 0.75,
497
+ timeout_ms: timeoutMs,
498
+ settle: false,
499
+ }),
434
500
  async waitForWallet(timeoutMs) {
435
501
  const deadline = Date.now() + timeoutMs;
502
+ let stableCaptures = 0;
436
503
  while (Date.now() <= deadline) {
437
- if ((await this.screenState()).wallet) return;
504
+ const state = await this.screenState();
505
+ if (state.wallet && state.visible.has(TEST_IDS.walletAddress)) {
506
+ stableCaptures += 1;
507
+ if (stableCaptures >= 2) return;
508
+ } else {
509
+ stableCaptures = 0;
510
+ }
438
511
  await new Promise((resolve) => setTimeout(resolve, 250));
439
512
  }
440
513
  throw new Error('Mobile wallet home did not become visible before the readiness timeout.');
@@ -87,6 +87,7 @@ export function resolveVisibleStateOptions(node = {}) {
87
87
  maximumOrderCount: boundedInteger(node.maximum_order_count, 200, 0, 200, 'maximum_order_count'),
88
88
  minimumActivityCount: boundedInteger(node.minimum_activity_count, 0, 0, 200, 'minimum_activity_count'),
89
89
  requireNoHorizontalOverflow: node.require_no_horizontal_overflow === true,
90
+ requireNoControlOverlap: node.require_no_control_overlap === true,
90
91
  requireFeatures: stringList(node.require_features, 'require_features').map((feature) => {
91
92
  if (!FEATURES.has(feature)) {
92
93
  throw new Error(
@@ -132,6 +133,7 @@ function marketFromTestId(testId) {
132
133
  for (const prefix of ['perps-watchlist-', 'explore-markets-', 'perps-market-row-item-', 'market-row-']) {
133
134
  if (!testId.startsWith(prefix)) continue;
134
135
  const raw = testId.slice(prefix.length);
136
+ if (/(?:-token-logo|-asset-label)$/u.test(raw)) return undefined;
135
137
  if ((prefix === 'explore-markets-' || prefix === 'market-row-') && raw.startsWith('xyz-')) {
136
138
  return `XYZ:${raw.slice(4).toUpperCase()}`;
137
139
  }
@@ -142,6 +144,9 @@ function marketFromTestId(testId) {
142
144
 
143
145
  function classifySurface(route, testIds) {
144
146
  const joined = [...testIds].join('\n');
147
+ if (/confirm-transaction\/.*[?&]goBackTo=%2Fperps-home(?:&|$)/u.test(route)) {
148
+ return 'funds';
149
+ }
145
150
  if (/\/perps\/trade\//u.test(route)) {
146
151
  return 'order-entry';
147
152
  }
@@ -173,18 +178,19 @@ function compactEntries(items, maximum) {
173
178
  function visibleFeatures(items) {
174
179
  const ids = new Set(items.map((item) => item.testId));
175
180
  const has = (pattern) => [...ids].some((testId) => pattern.test(testId));
176
- const long = has(/(?:long-cta-button|direction-long)$/u);
177
- const short = has(/(?:short-cta-button|direction-short)$/u);
181
+ const long = has(/(?:long-cta-button|direction-long|market-details-long-button)$/u);
182
+ const short = has(/(?:short-cta-button|direction-short|market-details-short-button)$/u);
178
183
  return {
179
184
  activity: has(/^(?:transaction-card-|perps-(?:recent-activity|activity))/u),
180
- balance: has(/^perps-balance-dropdown/u),
181
- chart: has(/perps-(?:candlestick-chart|market-detail-chart|pro-market-chart)/u),
185
+ balance: has(/^(?:perps-balance-dropdown|perps-market-(?:balance-value|available-balance-text))/u),
186
+ chart: has(/^(?:chart_surface|tv_chart_container)$/u) ||
187
+ has(/perps-(?:candlestick-chart|market-detail-chart|market-details-fullscreen-chart|pro-market-chart)/u),
182
188
  'long-short': long && short,
183
189
  'market-list': has(/^(?:market-list-view|perps-market-list|perps-watchlist-|explore-markets-|market-row-)/u),
184
190
  'order-entry': has(/perps-(?:order-entry|pro-order-form)/u),
185
- price: has(/perps-(?:market-detail-price|pro-market-price)$/u),
186
- 'price-change': has(/perps-(?:market-detail-change|pro-market-price-change)$/u),
187
- stats: has(/perps-(?:stats-section|market-detail-oracle-price|pro-market-stats)/u),
191
+ price: has(/perps-(?:market-detail-price|market-header-price|pro-market-price)$/u),
192
+ 'price-change': has(/perps-(?:market-detail-change|market-header-price-change|pro-market-price-change)$/u),
193
+ stats: has(/perps-(?:stats-section|market-details-market-summary|market-detail-oracle-price|pro-market-stats)/u),
188
194
  };
189
195
  }
190
196
 
@@ -213,20 +219,20 @@ export function normalizeVisiblePerpsState(raw, options, platform) {
213
219
  ...(route ? { route } : {}),
214
220
  ...(text(raw.title) ? { title: text(raw.title) } : {}),
215
221
  balance: compactEntries(
216
- selected(unique, (item) => item.testId.startsWith('perps-balance-dropdown'), 1),
222
+ selected(unique, (item) => /^(?:perps-balance-dropdown|perps-market-balance-value)$/u.test(item.testId), 1),
217
223
  1,
218
224
  )[0] ?? null,
219
225
  markets: marketItems,
220
226
  positions: compactEntries(
221
- selected(unique, (item) => /^(?:position-card-|perps-position-row-|perps-position-size-value$|perps-pro-market-position-row-)/u.test(item.testId), options.maxItems),
227
+ selected(unique, (item) => /^(?:position-card-(?!roe-)|perps-position-row-|perps-position-size-value$|perps-pro-market-position-row-|perps-home-position-card-)/u.test(item.testId), options.maxItems),
222
228
  options.maxItems,
223
229
  ),
224
230
  orders: compactEntries(
225
- selected(unique, (item) => /^(?:order-card-|perps-order-row-)/u.test(item.testId), options.maxItems),
231
+ selected(unique, (item) => /^(?:order-card-|perps-order-row-|perps-home-order-card-)/u.test(item.testId), options.maxItems),
226
232
  options.maxItems,
227
233
  ),
228
234
  activity: compactEntries(
229
- selected(unique, (item) => /^(?:transaction-card-|perps-activity-row-)/u.test(item.testId), options.maxItems),
235
+ selected(unique, (item) => /^(?:transaction-card-|transaction-item(?:-|$)|perps-activity-row-)/u.test(item.testId), options.maxItems),
230
236
  options.maxItems,
231
237
  ),
232
238
  orderEntry: raw.orderEntry && typeof raw.orderEntry === 'object' ? {
@@ -267,6 +273,16 @@ export function normalizeVisiblePerpsState(raw, options, platform) {
267
273
  clippedTestIds: Array.isArray(raw.layout.clippedTestIds)
268
274
  ? raw.layout.clippedTestIds.map(text).filter(Boolean).slice(0, 100)
269
275
  : [],
276
+ obscuredControlTestIds: Array.isArray(raw.layout.obscuredControlTestIds)
277
+ ? raw.layout.obscuredControlTestIds.map(text).filter(Boolean).slice(0, 100)
278
+ : [],
279
+ overlappingControlPairs: Array.isArray(raw.layout.overlappingControlPairs)
280
+ ? raw.layout.overlappingControlPairs
281
+ .filter((pair) => Array.isArray(pair) && pair.length === 2)
282
+ .map((pair) => pair.map(text))
283
+ .filter((pair) => pair.every(Boolean))
284
+ .slice(0, 100)
285
+ : [],
270
286
  } : null,
271
287
  };
272
288
  assertVisibleState(state, options);
@@ -279,6 +295,12 @@ function assertVisibleState(state, options) {
279
295
  )) {
280
296
  throw new Error(`Visible Perps layout overflows horizontally: ${JSON.stringify(state.layout)}.`);
281
297
  }
298
+ if (options.requireNoControlOverlap && (
299
+ !state.layout || state.layout.obscuredControlTestIds.length > 0 ||
300
+ state.layout.overlappingControlPairs.length > 0
301
+ )) {
302
+ throw new Error(`Visible Perps controls overlap: ${JSON.stringify(state.layout)}.`);
303
+ }
282
304
  if (options.surface !== 'auto' && state.surface !== options.surface) {
283
305
  throw new Error(
284
306
  `Expected visible Perps surface ${options.surface}, but observed ${state.surface}.`,
@@ -17,6 +17,8 @@ export function resolveVisibleSwapBridgeOptions(node = {}) {
17
17
  destinationSymbol: symbol(node.destination_symbol),
18
18
  requireAmountInputs: node.require_amount_inputs !== false,
19
19
  requireSettings: node.require_settings === true,
20
+ requireQuote: node.require_quote === true,
21
+ expectedSlippage: node.expected_slippage == null ? null : Number(node.expected_slippage),
20
22
  };
21
23
  }
22
24
 
@@ -42,6 +44,11 @@ function matchesSymbol(value, expected) {
42
44
  .test(String(value ?? '').toUpperCase());
43
45
  }
44
46
 
47
+ function slippagePercent(value) {
48
+ const match = String(value ?? '').match(/(\d+(?:\.\d+)?)\s*%/u);
49
+ return match ? Number(match[1]) : Number.NaN;
50
+ }
51
+
45
52
  export function normalizeVisibleSwapBridgeState(raw, options, platform) {
46
53
  const items = [...(raw.items ?? []), ...(raw.offscreenItems ?? [])]
47
54
  .map(normalizedItem)
@@ -55,6 +62,7 @@ export function normalizeVisibleSwapBridgeState(raw, options, platform) {
55
62
  const settings = byId.get(platform === 'extension' ? 'bridge__header-settings-button' : 'bridge-slippage-settings-button');
56
63
  const noQuotes = byId.get(platform === 'extension' ? 'prepare-swap-page-error' : 'bridge-no-quotes');
57
64
  const missingPrice = byId.get(platform === 'extension' ? 'bridge-missing-price-banner' : 'bridge-missing-price-banner');
65
+ const slippage = byId.get(platform === 'extension' ? 'slippage-edit-button' : 'edit-slippage-button');
58
66
  const state = {
59
67
  source: 'visible-ui',
60
68
  platform,
@@ -65,6 +73,7 @@ export function normalizeVisibleSwapBridgeState(raw, options, platform) {
65
73
  destinationAsset,
66
74
  sourceAmount: text(sourceInput?.value ?? sourceInput?.label) ?? null,
67
75
  destinationAmount: text(destinationInput?.value ?? destinationInput?.label) ?? null,
76
+ slippage: text(raw.slippage ?? raw.quote?.slippage ?? slippage?.value ?? slippage?.label) ?? null,
68
77
  controls: unique.slice(0, 100),
69
78
  features: {
70
79
  'amount-inputs': Boolean(sourceInput && destinationInput),
@@ -88,6 +97,12 @@ function assertVisibleSwapBridgeState(state, options) {
88
97
  if (options.requireSettings && !state.features.settings) {
89
98
  throw new Error('Visible Swap settings control was not observed.');
90
99
  }
100
+ if (options.requireQuote && !(Number(state.destinationAmount) > 0)) {
101
+ throw new Error('Visible Swap quote did not contain a positive destination amount.');
102
+ }
103
+ if (options.expectedSlippage !== null && slippagePercent(state.slippage) !== options.expectedSlippage) {
104
+ throw new Error(`Expected visible Swap slippage ${options.expectedSlippage}%, but observed ${JSON.stringify(state.slippage)}.`);
105
+ }
91
106
  if (!matchesSymbol(state.sourceAsset, options.sourceSymbol)) {
92
107
  throw new Error(`Expected visible Swap source ${options.sourceSymbol}, but observed ${JSON.stringify(state.sourceAsset)}.`);
93
108
  }