@deeeed/metamask-harness 0.37.0 → 0.38.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 (113) hide show
  1. package/CHANGELOG.md +27 -0
  2. package/adapters/extension/artifact-runtime-state.cjs +13 -3
  3. package/adapters/extension/inject.mjs +1 -0
  4. package/adapters/extension/launch-browser.cjs +2 -0
  5. package/adapters/extension/live.sh +1 -1
  6. package/adapters/extension/readiness.mjs +1 -1
  7. package/adapters/manifest.json +8 -0
  8. package/dist/adapters/extension/runtime-decision.js +4 -1
  9. package/dist/adapters/extension/runtime.js +42 -3
  10. package/dist/adapters/mobile/provision.js +38 -3
  11. package/dist/adapters.js +68 -3
  12. package/dist/app-lifecycle.js +4 -3
  13. package/dist/commands/call.js +4 -1
  14. package/dist/commands/launch/extension.js +21 -5
  15. package/dist/commands/launch/index.js +3 -0
  16. package/dist/commands/run-engine.js +40 -29
  17. package/dist/commands/run.js +1 -1
  18. package/dist/recipe-security.js +40 -0
  19. package/dist/runner.js +3 -0
  20. package/docs/RELEASE-QA-CAPABILITY-MAP.md +116 -141
  21. package/library/actions/extension/assets/finish_send.mjs +128 -0
  22. package/library/actions/extension/assets/import_custom_token.mjs +227 -0
  23. package/library/actions/extension/assets/prepare_send.mjs +53 -0
  24. package/library/actions/extension/assets/read_details.mjs +57 -0
  25. package/library/actions/extension/assets/read_visible_state.mjs +50 -0
  26. package/library/actions/extension/assets/review_send.mjs +23 -0
  27. package/library/actions/extension/assets/select_network_scope.mjs +30 -0
  28. package/library/actions/extension/assets/send.mjs +104 -0
  29. package/library/actions/extension/assets/set_custom_gas.mjs +56 -0
  30. package/library/actions/extension/assets/set_token_visibility.mjs +129 -0
  31. package/library/actions/extension/assets/verify_sorting.mjs +89 -0
  32. package/library/actions/extension/networks/add_chainlist.mjs +94 -0
  33. package/library/actions/extension/networks/add_custom.mjs +123 -0
  34. package/library/actions/extension/networks/custom_network.mjs +103 -0
  35. package/library/actions/extension/networks/read_visible_state.mjs +50 -0
  36. package/library/actions/extension/networks/remove_custom.mjs +68 -0
  37. package/library/actions/extension/perps/close_orders.mjs +1 -0
  38. package/library/actions/extension/perps/close_visible_position.mjs +133 -0
  39. package/library/actions/extension/perps/compare_provider_market.mjs +109 -0
  40. package/library/actions/extension/perps/edit_margin.mjs +82 -0
  41. package/library/actions/extension/perps/open_balance_action.mjs +58 -0
  42. package/library/actions/extension/perps/open_position_action.mjs +80 -0
  43. package/library/actions/extension/perps/perps.mjs +194 -2
  44. package/library/actions/extension/perps/place_order.mjs +5 -2
  45. package/library/actions/extension/perps/read_eligibility.mjs +34 -0
  46. package/library/actions/extension/perps/read_funds_confirmation.mjs +69 -0
  47. package/library/actions/extension/perps/read_visible_state.mjs +98 -0
  48. package/library/actions/extension/perps/select_activity_filter.mjs +44 -0
  49. package/library/actions/extension/perps/select_market_filter.mjs +71 -0
  50. package/library/actions/extension/perps/set_market_favorite.mjs +47 -0
  51. package/library/actions/extension/perps/update_position_tpsl.mjs +106 -0
  52. package/library/actions/extension/platform/cdp.mjs +104 -1
  53. package/library/actions/extension/settings/set_basic_functionality.mjs +58 -0
  54. package/library/actions/extension/swap_bridge/read_visible_state.mjs +71 -0
  55. package/library/actions/extension/swap_bridge/select_assets.mjs +103 -0
  56. package/library/actions/extension/swap_bridge/set_amount.mjs +36 -0
  57. package/library/actions/extension/swap_bridge/set_max_amount.mjs +58 -0
  58. package/library/actions/extension/swap_bridge/set_slippage.mjs +121 -0
  59. package/library/actions/extension/swap_bridge/submit_transaction.mjs +363 -0
  60. package/library/actions/extension/ui/activate_and_observe.mjs +96 -0
  61. package/library/actions/extension/ui/navigate.mjs +264 -8
  62. package/library/actions/extension/wallet/ensure_unlocked.mjs +2 -6
  63. package/library/actions/extension/wallet/lock.mjs +35 -0
  64. package/library/actions/extension/wallet/select_account.mjs +71 -7
  65. package/library/actions/mobile/assets/read_visible_state.mjs +37 -0
  66. package/library/actions/mobile/perps/read_visible_state.mjs +31 -0
  67. package/library/actions/mobile/platform/native-session-name.mjs +5 -0
  68. package/library/actions/mobile/platform/observe-ui.mjs +5 -3
  69. package/library/actions/mobile/swap_bridge/native-session.mjs +45 -0
  70. package/library/actions/mobile/swap_bridge/read_visible_state.mjs +31 -0
  71. package/library/actions/mobile/swap_bridge/select_assets.mjs +94 -0
  72. package/library/actions/mobile/swap_bridge/set_amount.mjs +73 -0
  73. package/library/actions/mobile/ui/native-navigation.mjs +45 -5
  74. package/library/actions/mobile/ui/navigate.mjs +3 -1
  75. package/library/actions/mobile/wallet/ensure_unlocked.mjs +17 -0
  76. package/library/actions/mobile/wallet/native-ui.mjs +21 -2
  77. package/library/actions/shared/assets/visible-state.mjs +176 -0
  78. package/library/actions/shared/networks/visible-state.mjs +70 -0
  79. package/library/actions/shared/perps/visible-state.mjs +354 -0
  80. package/library/actions/shared/swap-bridge/transaction.mjs +54 -0
  81. package/library/actions/shared/swap-bridge/visible-state.mjs +97 -0
  82. package/library/manifests/extension.action-manifest.json +1255 -189
  83. package/library/manifests/mobile.action-manifest.json +219 -1
  84. package/library/recipes/assets/release-custom-gas-send.recipe.json +88 -0
  85. package/library/recipes/assets/release-custom-network-token-persistence.recipe.json +106 -0
  86. package/library/recipes/assets/release-native-max-cancel.recipe.json +70 -0
  87. package/library/recipes/assets/release-native-max-send.recipe.json +78 -0
  88. package/library/recipes/assets/release-own-account-send.recipe.json +80 -0
  89. package/library/recipes/assets/release-token-address-import.recipe.json +47 -0
  90. package/library/recipes/assets/release-token-details.recipe.json +48 -0
  91. package/library/recipes/assets/release-token-hide-readd.recipe.json +55 -0
  92. package/library/recipes/assets/release-token-list.recipe.json +75 -0
  93. package/library/recipes/assets/release-token-persistence.recipe.json +73 -0
  94. package/library/recipes/assets/release-token-search-import.recipe.json +40 -0
  95. package/library/recipes/assets/release-token-sorting.recipe.json +29 -0
  96. package/library/recipes/extension/networks/release-balance-stability.recipe.json +51 -0
  97. package/library/recipes/extension/perps/release-live-limit-order.recipe.json +104 -0
  98. package/library/recipes/extension/perps/release-order-entry.recipe.json +155 -0
  99. package/library/recipes/extension/perps/release-order-validation.recipe.json +220 -0
  100. package/library/recipes/perps/release-activity-filters.recipe.json +51 -0
  101. package/library/recipes/perps/release-funds-flows.recipe.json +76 -0
  102. package/library/recipes/perps/release-live-market-order.recipe.json +122 -0
  103. package/library/recipes/perps/release-market-details.recipe.json +83 -0
  104. package/library/recipes/perps/release-market-search.recipe.json +79 -0
  105. package/library/recipes/perps/release-watchlist.recipe.json +99 -0
  106. package/library/recipes/swap-bridge/release-custom-slippage.recipe.json +90 -0
  107. package/library/recipes/swap-bridge/release-live-max-swap.recipe.json +99 -0
  108. package/library/recipes/swap-bridge/release-live-swap.recipe.json +101 -0
  109. package/library/recipes/swap-bridge/release-max-quote.recipe.json +72 -0
  110. package/library/recipes/swap-bridge/release-quote.recipe.json +72 -0
  111. package/library/recipes/swap-bridge/release-surface.recipe.json +43 -0
  112. package/package.json +6 -5
  113. package/scripts/validate-release-report.mjs +319 -0
@@ -204,6 +204,51 @@ export async function closeOrders(input) {
204
204
  requireExplicitSelection(input);
205
205
  return withExtensionPage(input, async (page) => {
206
206
  const timeoutMs = Number(input.node?.timeout_ms ?? 30000);
207
+ const hasBackgroundRequest = await page.evaluate(
208
+ `typeof globalThis.stateHooks?.submitRequestToBackground === 'function'`,
209
+ );
210
+ if (!hasBackgroundRequest) {
211
+ if (input.node?.confirm_live !== true) {
212
+ throw new Error('metamask.perps.close_orders requires confirm_live=true for visible live cancellation.');
213
+ }
214
+ const selector = input.node?.selector && typeof input.node.selector === 'object' ? input.node.selector : {};
215
+ if (input.node?.side != null || selector.side != null) {
216
+ throw new Error('Visible Perps order cancellation cannot prove an order side. Next: select one market without side or use a runtime with background state hooks.');
217
+ }
218
+ const symbols = configuredSymbols(input, []);
219
+ if (symbols.length !== 1) {
220
+ throw new Error('Visible Perps order cancellation requires exactly one market.');
221
+ }
222
+ const symbol = symbols[0];
223
+ await page.navigateHash(`#/perps/market/${encodeURIComponent(symbol)}`);
224
+ const orderSelector = '[data-testid^="order-card-"]';
225
+ await page.waitForSelector(orderSelector, { timeoutMs });
226
+ let canceled = 0;
227
+ while (await page.evaluate(`Boolean(document.querySelector(${JSON.stringify(orderSelector)}))`)) {
228
+ const beforeCount = await page.evaluate(`document.querySelectorAll(${JSON.stringify(orderSelector)}).length`);
229
+ await page.click(orderSelector);
230
+ await page.waitForSelector(dataTestId('perps-cancel-order-modal'), { timeoutMs });
231
+ await page.click(dataTestId('perps-cancel-order-button'));
232
+ canceled += 1;
233
+ const deadline = Date.now() + timeoutMs;
234
+ while (Date.now() < deadline) {
235
+ const state = await page.evaluate(`({
236
+ modal: Boolean(document.querySelector(${JSON.stringify(dataTestId('perps-cancel-order-modal'))})),
237
+ orders: document.querySelectorAll(${JSON.stringify(orderSelector)}).length,
238
+ })`);
239
+ if (!state.modal && state.orders < beforeCount) break;
240
+ await sleep(250);
241
+ }
242
+ if (canceled >= 100) throw new Error('Visible Perps order cancellation exceeded 100 orders.');
243
+ }
244
+ return {
245
+ action: input.action,
246
+ canceled: canceled > 0,
247
+ matchingCount: canceled,
248
+ symbols: [symbol],
249
+ proofPath: 'visible-product-controls',
250
+ };
251
+ }
207
252
  const before = await readOpenOrders(page);
208
253
  const matching = selectedItems(input, before);
209
254
  const symbols = uniqueSymbols(matching.map(symbolForItem));
@@ -463,8 +508,17 @@ export async function placeOrder(input) {
463
508
  const leverage = Number(input.node?.leverage ?? 3);
464
509
  const isBuy = side !== 'short';
465
510
  const orderType = resolveOrderType(input);
511
+ const positionMode = String(input.node?.position_mode ?? 'new').toLowerCase();
512
+ const submitAttempts = Number(input.node?.submit_attempts ?? 1);
513
+ if (positionMode !== 'new' && positionMode !== 'modify') {
514
+ throw new Error(`${input.action} received unsupported position_mode: ${positionMode}.`);
515
+ }
516
+ if (!Number.isInteger(submitAttempts) || submitAttempts < 1 || submitAttempts > 2) {
517
+ throw new Error(`${input.action} submit_attempts must be 1 or 2.`);
518
+ }
466
519
  return withExtensionPage(input, async (page) => {
467
520
  await page.navigateHash(`#/perps/market/${encodeURIComponent(symbol)}`);
521
+ await page.waitForSelector(dataTestId('perps-market-detail-pair'), { timeoutMs: 20_000 });
468
522
  await page.evaluate(`(async () => {
469
523
  const request = globalThis.stateHooks?.submitRequestToBackground;
470
524
  if (typeof request !== 'function') return;
@@ -482,8 +536,146 @@ export async function placeOrder(input) {
482
536
  const limitPrice = orderType === 'limit'
483
537
  ? resolveLimitPrice(input, isBuy, resolvedPrice)
484
538
  : null;
485
- await page.navigateHash(`#/perps/trade/${encodeURIComponent(symbol)}?direction=${encodeURIComponent(side)}&mode=new`);
539
+ const initialPositionSize = positionMode === 'modify'
540
+ ? await page.evaluate(`document.querySelector('[data-testid="perps-position-size-value"]')?.textContent?.trim() ?? null`)
541
+ : null;
542
+ const initialOpenOrderCount = orderType === 'limit'
543
+ ? await page.evaluate(`document.querySelectorAll('[data-testid^="order-card-"]').length`)
544
+ : null;
545
+ await page.navigateHash(`#/perps/trade/${encodeURIComponent(symbol)}?direction=${encodeURIComponent(side)}&mode=${positionMode}`);
486
546
  await page.waitForSelector(dataTestId('perps-order-entry-page'), { timeoutMs: 20000 });
547
+ const hasBackgroundRequest = await page.evaluate(
548
+ `typeof globalThis.stateHooks?.submitRequestToBackground === 'function'`,
549
+ );
550
+ if (!hasBackgroundRequest) {
551
+ if (input.node?.confirm_live !== true) {
552
+ throw new Error('metamask.perps.place_order requires confirm_live=true for visible live submission.');
553
+ }
554
+ await page.setInput(`${dataTestId('amount-input-field')} input`, amount);
555
+ await page.setInput(`${dataTestId('leverage-input')} input`, String(leverage));
556
+ if (orderType === 'limit') {
557
+ await page.click(dataTestId('order-type-limit'));
558
+ const limitInput = `${dataTestId('limit-price-input')} input`;
559
+ await page.waitForSelector(limitInput, { timeoutMs: 10000 });
560
+ await page.setInput(limitInput, String(limitPrice));
561
+ }
562
+ const configuredDeadline = Date.now() + 10_000;
563
+ let configured;
564
+ while (Date.now() < configuredDeadline) {
565
+ configured = await page.evaluate(`(() => {
566
+ const value = (testId) => document.querySelector('[data-testid="' + testId + '"] input')?.value ?? null;
567
+ const limit = document.querySelector('[data-testid="limit-price-input"] input')?.value ?? null;
568
+ const submit = document.querySelector('[data-testid="submit-order-button"]');
569
+ return {
570
+ amount: value('amount-input-field'),
571
+ leverage: value('leverage-input'),
572
+ limitPrice: limit,
573
+ limitSelected: document.querySelector('[data-testid="order-type-limit"]')?.getAttribute('aria-pressed') === 'true',
574
+ submitEnabled: Boolean(submit && !submit.disabled && submit.getAttribute('aria-disabled') !== 'true'),
575
+ validation: [...document.querySelectorAll('[role="alert"], [data-testid$="validation-error"], [data-testid$="warning"]')]
576
+ .map((element) => String(element.innerText || '').replace(/\\s+/gu, ' ').trim())
577
+ .filter(Boolean)
578
+ .slice(0, 20),
579
+ };
580
+ })()`);
581
+ if (configured.submitEnabled) break;
582
+ await sleep(250);
583
+ }
584
+ if (Number(configured.amount) !== Number(amount) || Number(configured.leverage) !== leverage) {
585
+ throw new Error(`Visible Perps order inputs did not retain the requested values: ${JSON.stringify(configured)}.`);
586
+ }
587
+ if (orderType === 'limit' && (!configured.limitSelected || Number(configured.limitPrice) !== limitPrice)) {
588
+ throw new Error(`Visible Perps limit inputs did not retain the requested values: ${JSON.stringify(configured)}.`);
589
+ }
590
+ if (!configured.submitEnabled) {
591
+ throw new Error(`Visible Perps order is not submittable: ${JSON.stringify(configured)}.`);
592
+ }
593
+ const submitTarget = await page.evaluate(`(() => {
594
+ const element = document.querySelector('[data-testid="submit-order-button"]');
595
+ if (!element) throw new Error('Visible Perps submit control disappeared.');
596
+ element.scrollIntoView({ block: 'center', inline: 'center' });
597
+ const rect = element.getBoundingClientRect();
598
+ return { x: rect.left + rect.width / 2, y: rect.top + rect.height / 2 };
599
+ })()`);
600
+ for (let attempt = 0; attempt < submitAttempts; attempt += 1) {
601
+ await page.session.call('Input.dispatchMouseEvent', {
602
+ type: 'mousePressed',
603
+ x: submitTarget.x,
604
+ y: submitTarget.y,
605
+ button: 'left',
606
+ buttons: 1,
607
+ clickCount: 1,
608
+ });
609
+ await page.session.call('Input.dispatchMouseEvent', {
610
+ type: 'mouseReleased',
611
+ x: submitTarget.x,
612
+ y: submitTarget.y,
613
+ button: 'left',
614
+ buttons: 0,
615
+ clickCount: 1,
616
+ });
617
+ }
618
+ const resultSelector = orderType === 'limit'
619
+ ? '[data-testid^="order-card-"]'
620
+ : dataTestId('perps-position-size-value');
621
+ const resultDeadline = Date.now() + Number(input.node?.timeout_ms ?? 45000);
622
+ let outcome;
623
+ while (Date.now() < resultDeadline) {
624
+ outcome = await page.evaluate(`(() => ({
625
+ success: ${JSON.stringify(positionMode)} === 'modify'
626
+ ? String(location.hash).startsWith(${JSON.stringify(`#/perps/market/${symbol}`)}) &&
627
+ String(document.querySelector('[data-testid="perps-position-size-value"]')?.textContent || '').trim() !== ${JSON.stringify(initialPositionSize)}
628
+ : Boolean(document.querySelector(${JSON.stringify(resultSelector)})),
629
+ route: String(location.hash),
630
+ submit: (() => {
631
+ const element = document.querySelector('[data-testid="submit-order-button"]');
632
+ return element ? {
633
+ label: String(element.innerText || '').replace(/\\s+/gu, ' ').trim(),
634
+ enabled: !element.disabled && element.getAttribute('aria-disabled') !== 'true',
635
+ } : null;
636
+ })(),
637
+ errors: [...document.querySelectorAll('[data-testid="perps-order-submit-error"], [role="alert"]')]
638
+ .map((element) => String(element.innerText || '').replace(/\\s+/gu, ' ').trim())
639
+ .filter(Boolean)
640
+ .slice(0, 20),
641
+ }))()`);
642
+ if (outcome.success) break;
643
+ if (outcome.errors.length > 0) {
644
+ throw new Error(`Visible Perps order submission failed: ${outcome.errors.join(' | ')}`);
645
+ }
646
+ await sleep(250);
647
+ }
648
+ if (!outcome?.success) {
649
+ throw new Error(`Visible Perps order submission did not produce ${resultSelector}; observed ${JSON.stringify(outcome)}.`);
650
+ }
651
+ let openOrderCount = null;
652
+ if (orderType === 'limit') {
653
+ await sleep(2_000);
654
+ await page.navigateHash(`#/perps/market/${encodeURIComponent(symbol)}`);
655
+ await page.waitForSelector(dataTestId('perps-market-detail-pair'), { timeoutMs: 20_000 });
656
+ openOrderCount = await page.evaluate(`document.querySelectorAll('[data-testid^="order-card-"]').length`);
657
+ if (openOrderCount !== initialOpenOrderCount + 1) {
658
+ throw new Error(`Perps limit submission created an unexpected open-order count: before=${initialOpenOrderCount}, after=${openOrderCount}.`);
659
+ }
660
+ }
661
+ return {
662
+ action: input.action,
663
+ market: symbol,
664
+ side,
665
+ orderType,
666
+ positionMode,
667
+ amount,
668
+ leverage,
669
+ limitPrice,
670
+ submitted: true,
671
+ submitAttempts,
672
+ initialOpenOrderCount,
673
+ openOrderCount,
674
+ configured,
675
+ outcome,
676
+ proofPath: 'visible-product-controls',
677
+ };
678
+ }
487
679
  let order;
488
680
  try {
489
681
  order = await page.evaluate(`(async () => {
@@ -532,7 +724,7 @@ export async function placeOrder(input) {
532
724
  } else {
533
725
  await waitForPositionPresent(page, symbol, Number(input.node?.timeout_ms ?? 30000));
534
726
  }
535
- return { action: input.action, market: symbol, side, orderType, amount, leverage, limitPrice, submitted: true, order, proofPath: 'background-perpsPlaceOrder' };
727
+ return { action: input.action, market: symbol, side, orderType, positionMode, amount, leverage, limitPrice, submitted: true, order, proofPath: 'background-perpsPlaceOrder' };
536
728
  });
537
729
  }
538
730
 
@@ -1,7 +1,10 @@
1
1
  import { runAdapter } from '../platform/cdp.mjs';
2
- import { assertPositions, placeOrder } from './perps.mjs';
2
+ import { assertOrders, assertPositions, placeOrder } from './perps.mjs';
3
3
  runAdapter(async (input) => {
4
4
  const order = await placeOrder(input);
5
- const assertion = await assertPositions(input, true);
5
+ if (order.proofPath === 'visible-product-controls') return order;
6
+ const assertion = order.orderType === 'limit'
7
+ ? await assertOrders(input, true)
8
+ : await assertPositions(input, true);
6
9
  return { ...assertion, submitted: order.submitted === true, order };
7
10
  });
@@ -0,0 +1,34 @@
1
+ import { runAdapter, withExtensionPage } from '../platform/cdp.mjs';
2
+
3
+ runAdapter(async (input) => {
4
+ const endpoint = 'https://geolocation.api.cx.metamask.io/v2/geolocation';
5
+ const response = await fetch(endpoint, { signal: AbortSignal.timeout(Number(input.node?.timeout_ms ?? 15_000)) });
6
+ if (!response.ok) throw new Error(`MetaMask geolocation request failed with HTTP ${response.status}.`);
7
+ const geolocation = await response.json();
8
+ return withExtensionPage(input, async (page) => {
9
+ const product = await page.evaluate(`(async () => {
10
+ const hooks = globalThis.stateHooks || {};
11
+ const root = (await hooks.getCleanAppState?.()) || hooks.store?.getState?.() || {};
12
+ const metamask = root.metamask || root;
13
+ return {
14
+ isEligible: metamask.isEligible ?? null,
15
+ geoBlockModalVisible: Boolean(document.querySelector('[data-testid="perps-geo-block-modal"]')),
16
+ route: String(location.hash),
17
+ };
18
+ })()`, { awaitPromise: true });
19
+ if (typeof product.isEligible !== 'boolean') {
20
+ throw new Error('The release UI did not expose the current Perps eligibility state.');
21
+ }
22
+ return {
23
+ action: input.action,
24
+ endpoint,
25
+ geolocation: {
26
+ country: geolocation?.country ?? null,
27
+ region: geolocation?.region ?? null,
28
+ timezone: geolocation?.timezone ?? null,
29
+ },
30
+ product,
31
+ proofPath: 'release-state-plus-live-geolocation',
32
+ };
33
+ });
34
+ });
@@ -0,0 +1,69 @@
1
+ import { runAdapter, withExtensionPage } from '../platform/cdp.mjs';
2
+
3
+ runAdapter(async (input) => withExtensionPage(input, async (page) => {
4
+ const mode = String(input.node?.mode ?? '').trim().toLowerCase();
5
+ if (mode !== 'deposit' && mode !== 'withdraw') {
6
+ throw new Error('metamask.perps.read_funds_confirmation mode must be deposit or withdraw.');
7
+ }
8
+ const expectedTitle = mode === 'deposit' ? 'Deposit funds to Perps' : 'Withdraw';
9
+ const deadline = Date.now() + 30_000;
10
+ let observed;
11
+ while (Date.now() <= deadline) {
12
+ observed = await page.evaluate(`(() => {
13
+ const read = (testId) => String(document.querySelector('[data-testid="' + testId + '"]')?.innerText || '')
14
+ .replace(/\\s+/gu, ' ')
15
+ .trim()
16
+ .slice(0, 500);
17
+ const amount = document.querySelector('[data-testid="custom-amount-input"]');
18
+ const submit = document.querySelector('[data-testid="confirm-footer-button"]');
19
+ return {
20
+ route: String(location.hash),
21
+ title: String(document.body?.innerText || '').trim().split(/\\n+/u)[0] || '',
22
+ amount: amount && 'value' in amount ? String(amount.value) : null,
23
+ availableBalance: read('perps-withdraw-balance-value'),
24
+ transactionFee: read('transaction-fee-value'),
25
+ estimatedTime: read('bridge-time-value'),
26
+ receive: read('receive-value'),
27
+ payToken: String(document.body?.innerText || '').match(/Pay with\\s+([^\\n]+)/u)?.[1] || '',
28
+ submitLabel: String(submit?.innerText || '').trim(),
29
+ submitEnabled: Boolean(submit && !submit.disabled && submit.getAttribute('aria-disabled') !== 'true'),
30
+ layout: {
31
+ viewportWidth: innerWidth,
32
+ viewportHeight: innerHeight,
33
+ contentWidth: Math.max(document.documentElement?.scrollWidth || 0, document.body?.scrollWidth || 0),
34
+ horizontalOverflow: Math.max(document.documentElement?.scrollWidth || 0, document.body?.scrollWidth || 0) > innerWidth + 1,
35
+ clippedTestIds: [...document.querySelectorAll('[data-testid]')]
36
+ .filter((element) => {
37
+ const style = getComputedStyle(element);
38
+ const rect = element.getBoundingClientRect();
39
+ return style.display !== 'none' && style.visibility !== 'hidden' && rect.width > 0 && rect.height > 0 &&
40
+ rect.bottom > 0 && rect.top < innerHeight && (rect.left < -1 || rect.right > innerWidth + 1);
41
+ })
42
+ .map((element) => element.getAttribute('data-testid'))
43
+ .filter(Boolean)
44
+ .slice(0, 100),
45
+ },
46
+ };
47
+ })()`);
48
+ if (observed.title === expectedTitle && observed.amount !== null) break;
49
+ await new Promise((resolve) => setTimeout(resolve, 100));
50
+ }
51
+ if (observed?.title !== expectedTitle || observed?.amount === null) {
52
+ throw new Error(`Perps ${mode} confirmation did not finish loading.`);
53
+ }
54
+ if (input.node?.require_no_horizontal_overflow === true && (
55
+ observed.layout.horizontalOverflow || observed.layout.clippedTestIds.length > 0
56
+ )) {
57
+ throw new Error(`Visible Perps funds layout overflows horizontally: ${JSON.stringify(observed.layout)}.`);
58
+ }
59
+ if (typeof input.node?.expected_submit_enabled === 'boolean'
60
+ && observed.submitEnabled !== input.node.expected_submit_enabled) {
61
+ throw new Error(`Perps ${mode} submit state mismatch: expected ${input.node.expected_submit_enabled}, observed ${observed.submitEnabled}.`);
62
+ }
63
+ return {
64
+ action: input.action,
65
+ mode,
66
+ ...observed,
67
+ proofPath: 'visible-dom-accessibility',
68
+ };
69
+ }));
@@ -0,0 +1,98 @@
1
+ import { runAdapter, withExtensionPage } from '../platform/cdp.mjs';
2
+ import {
3
+ normalizeVisiblePerpsState,
4
+ resolveVisibleStateOptions,
5
+ } from '../../shared/perps/visible-state.mjs';
6
+
7
+ runAdapter(async (input) => withExtensionPage(input, async (page) => {
8
+ const options = resolveVisibleStateOptions(input.node);
9
+ const raw = await page.evaluate(`(() => {
10
+ const includeOffscreen = ${JSON.stringify(options.includeOffscreen)};
11
+ const maximum = ${JSON.stringify(options.maxItems)};
12
+ const relevant = /^(?:perps-|explore-markets-|market-list-view|market-row-|filter-select-button|sort-dropdown-button|search-input|position-card-|order-card-|transaction-card-|bottom-nav-perps|direction-|order-type-|amount-|balance-percent-|leverage-|auto-close|submit-order-button|limit-price-|tp-|sl-)/u;
13
+ const items = [];
14
+ const offscreenItems = [];
15
+ const clippedTestIds = [];
16
+ let matched = 0;
17
+ for (const element of document.querySelectorAll('[data-testid]')) {
18
+ const testId = element.getAttribute('data-testid');
19
+ if (!testId || !relevant.test(testId)) continue;
20
+ const style = getComputedStyle(element);
21
+ const rect = element.getBoundingClientRect();
22
+ const rendered = style.display !== 'none' && style.visibility !== 'hidden' && rect.width > 0 && rect.height > 0;
23
+ if (!rendered) continue;
24
+ matched += 1;
25
+ const visible = rect.bottom > 0 && rect.right > 0 && rect.top < innerHeight && rect.left < innerWidth;
26
+ if (rect.bottom > 0 && rect.top < innerHeight && (rect.left < -1 || rect.right > innerWidth + 1)) {
27
+ clippedTestIds.push(testId);
28
+ }
29
+ if (!visible && !includeOffscreen) continue;
30
+ if (items.length + offscreenItems.length >= maximum) break;
31
+ const label = String(element.getAttribute('aria-label') || element.innerText || element.getAttribute('value') || '')
32
+ .replace(/\\s+/gu, ' ')
33
+ .trim()
34
+ .slice(0, 500);
35
+ const item = {
36
+ testId,
37
+ label,
38
+ role: element.getAttribute('role') || element.tagName.toLowerCase(),
39
+ enabled: !element.disabled && element.getAttribute('aria-disabled') !== 'true',
40
+ visible,
41
+ };
42
+ (visible ? items : offscreenItems).push(item);
43
+ }
44
+ const field = (testId) => {
45
+ const element = document.querySelector('[data-testid="' + testId + '"]');
46
+ const input = element?.matches('input') ? element : element?.querySelector('input');
47
+ if (!element) return null;
48
+ return {
49
+ testId,
50
+ label: String(element.innerText || element.getAttribute('aria-label') || '').replace(/\\s+/gu, ' ').trim().slice(0, 500),
51
+ value: input && 'value' in input ? String(input.value) : null,
52
+ checked: input?.checked === true,
53
+ enabled: !element.disabled && element.getAttribute('aria-disabled') !== 'true',
54
+ pressed: element.getAttribute('aria-pressed'),
55
+ };
56
+ };
57
+ const submit = field('submit-order-button');
58
+ const direction = submit?.label.match(/\\b(long|short)\\b/iu)?.[1]?.toLowerCase() ||
59
+ String(location.hash).match(/[?&]direction=(long|short)(?:&|$)/u)?.[1] ||
60
+ null;
61
+ return {
62
+ route: location.hash,
63
+ title: document.title,
64
+ items,
65
+ offscreenItems,
66
+ truncated: matched > items.length + offscreenItems.length,
67
+ orderEntry: document.querySelector('[data-testid="perps-order-entry-page"]') ? {
68
+ symbol: field('perps-order-entry-asset-symbol')?.label || null,
69
+ direction,
70
+ orderType: field('order-type-limit')?.pressed === 'true' ? 'limit' : 'market',
71
+ amount: field('amount-input-field')?.value,
72
+ percent: field('balance-percent-input')?.value,
73
+ leverage: field('leverage-input')?.value,
74
+ autoClose: field('auto-close-toggle')?.checked === true,
75
+ takeProfitPrice: field('tp-price-input')?.value,
76
+ stopLossPrice: field('sl-price-input')?.value,
77
+ submit,
78
+ limitPrice: field('limit-price-input')?.value,
79
+ validation: [...document.querySelectorAll('[data-testid$="validation-error"], [role="alert"]')]
80
+ .map((element) => String(element.innerText || '').replace(/\\s+/gu, ' ').trim())
81
+ .filter(Boolean)
82
+ .slice(0, 20),
83
+ } : null,
84
+ layout: {
85
+ viewportWidth: innerWidth,
86
+ viewportHeight: innerHeight,
87
+ contentWidth: Math.max(document.documentElement?.scrollWidth || 0, document.body?.scrollWidth || 0),
88
+ horizontalOverflow: Math.max(document.documentElement?.scrollWidth || 0, document.body?.scrollWidth || 0) > innerWidth + 1,
89
+ clippedTestIds: [...new Set(clippedTestIds)].slice(0, 100),
90
+ },
91
+ };
92
+ })()`);
93
+ return {
94
+ action: input.action,
95
+ ...normalizeVisiblePerpsState(raw, options, 'extension'),
96
+ proofPath: 'visible-dom-accessibility',
97
+ };
98
+ }));
@@ -0,0 +1,44 @@
1
+ import { dataTestId, runAdapter, withExtensionPage } from '../platform/cdp.mjs';
2
+
3
+ const FILTERS = new Map([
4
+ ['trades', 'trade'],
5
+ ['orders', 'order'],
6
+ ['funding', 'funding'],
7
+ ['deposits', 'deposit'],
8
+ ]);
9
+
10
+ runAdapter(async (input) => withExtensionPage(input, async (page) => {
11
+ const filter = String(input.node?.filter ?? '').trim().toLowerCase();
12
+ const option = FILTERS.get(filter);
13
+ if (!option) {
14
+ throw new Error(`metamask.perps.select_activity_filter received unsupported filter ${JSON.stringify(filter)}.`);
15
+ }
16
+ await page.click(dataTestId('perps-activity-filter-button'));
17
+ await page.click(dataTestId(`perps-activity-filter-option-${option}`));
18
+ await new Promise((resolve) => setTimeout(resolve, 300));
19
+ const observed = await page.evaluate(`(() => {
20
+ const button = document.querySelector('[data-testid="perps-activity-filter-button"]');
21
+ const entries = [...document.querySelectorAll('[data-testid^="transaction-card-"]')]
22
+ .slice(0, 200)
23
+ .map((element) => String(element.innerText || '').replace(/\\s+/gu, ' ').trim().slice(0, 500));
24
+ return {
25
+ route: String(location.hash),
26
+ selected: String(button?.innerText || '').trim(),
27
+ entries,
28
+ empty: String(document.body?.innerText || '').replace(/\\s+/gu, ' ').trim().slice(0, 2_000),
29
+ };
30
+ })()`);
31
+ if (observed.route !== '#/perps/activity') throw new Error('Perps activity filter left the activity route.');
32
+ if (observed.selected.toLowerCase() !== filter) {
33
+ throw new Error(`Expected Perps activity filter ${filter}, but observed ${observed.selected || 'none'}.`);
34
+ }
35
+ return {
36
+ action: input.action,
37
+ filter,
38
+ selected: observed.selected,
39
+ count: observed.entries.length,
40
+ entries: observed.entries,
41
+ empty: observed.entries.length === 0 ? observed.empty : undefined,
42
+ proofPath: 'trusted-pointer-visible-ui',
43
+ };
44
+ }));
@@ -0,0 +1,71 @@
1
+ import { dataTestId, runAdapter, withExtensionPage } from '../platform/cdp.mjs';
2
+ import {
3
+ normalizeVisiblePerpsState,
4
+ resolveVisibleStateOptions,
5
+ } from '../../shared/perps/visible-state.mjs';
6
+
7
+ const FILTERS = new Map([
8
+ ['all', 'all'],
9
+ ['crypto', 'crypto'],
10
+ ['stocks', 'stock'],
11
+ ['pre-ipo', 'pre-ipo'],
12
+ ['index', 'index'],
13
+ ['etf', 'etf'],
14
+ ['commodities', 'commodity'],
15
+ ['forex', 'forex'],
16
+ ['new', 'new'],
17
+ ]);
18
+
19
+ runAdapter(async (input) => withExtensionPage(input, async (page) => {
20
+ const filter = String(input.node?.filter ?? '').trim().toLowerCase();
21
+ const option = FILTERS.get(filter);
22
+ if (!option) {
23
+ throw new Error(`metamask.perps.select_market_filter received unsupported filter ${JSON.stringify(filter)}.`);
24
+ }
25
+ const options = resolveVisibleStateOptions({
26
+ surface: 'market-list',
27
+ include_offscreen: true,
28
+ max_items: input.node?.max_items ?? 100,
29
+ minimum_market_count: input.node?.minimum_market_count ?? 1,
30
+ maximum_market_count: input.node?.maximum_market_count ?? 200,
31
+ });
32
+ await page.click(dataTestId('filter-select-button'));
33
+ const optionSelector = dataTestId(`filter-select-option-${option}`);
34
+ const found = await page.evaluate(`(() => Boolean(document.querySelector(${JSON.stringify(optionSelector)})))()`);
35
+ if (!found) {
36
+ throw new Error(`Perps market filter ${JSON.stringify(filter)} is not available in this release.`);
37
+ }
38
+ await page.click(optionSelector);
39
+ await new Promise((resolve) => setTimeout(resolve, 300));
40
+ const raw = await page.evaluate(`(() => {
41
+ const maximum = ${JSON.stringify(options.maxItems)};
42
+ const items = [];
43
+ let matched = 0;
44
+ for (const element of document.querySelectorAll('[data-testid]')) {
45
+ const testId = element.getAttribute('data-testid');
46
+ if (!testId || !/^(?:market-list-view|market-row-|perps-token-logo-|filter-select-button)/u.test(testId)) continue;
47
+ const style = getComputedStyle(element);
48
+ const rect = element.getBoundingClientRect();
49
+ if (style.display === 'none' || style.visibility === 'hidden' || rect.width <= 0 || rect.height <= 0) continue;
50
+ matched += 1;
51
+ if (items.length >= maximum) break;
52
+ items.push({
53
+ testId,
54
+ label: String(element.getAttribute('aria-label') || element.innerText || element.getAttribute('value') || '')
55
+ .replace(/\\s+/gu, ' ')
56
+ .trim()
57
+ .slice(0, 500),
58
+ role: element.getAttribute('role') || element.tagName.toLowerCase(),
59
+ enabled: !element.disabled && element.getAttribute('aria-disabled') !== 'true',
60
+ visible: rect.bottom > 0 && rect.right > 0 && rect.top < innerHeight && rect.left < innerWidth,
61
+ });
62
+ }
63
+ return { route: location.hash, title: document.title, items, offscreenItems: [], truncated: matched > items.length };
64
+ })()`);
65
+ return {
66
+ action: input.action,
67
+ filter,
68
+ ...normalizeVisiblePerpsState(raw, options, 'extension'),
69
+ proofPath: 'trusted-pointer-visible-ui',
70
+ };
71
+ }));
@@ -0,0 +1,47 @@
1
+ import { dataTestId, runAdapter, withExtensionPage } from '../platform/cdp.mjs';
2
+
3
+ runAdapter(async (input) => withExtensionPage(input, async (page) => {
4
+ const market = String(input.node?.market ?? '').trim().toUpperCase();
5
+ if (!/^[A-Z0-9:_-]{2,20}$/u.test(market)) {
6
+ throw new Error('metamask.perps.set_market_favorite requires a valid market symbol.');
7
+ }
8
+ if (typeof input.node?.favorite !== 'boolean') {
9
+ throw new Error('metamask.perps.set_market_favorite requires favorite=true or false.');
10
+ }
11
+ const desired = input.node.favorite;
12
+ const selector = dataTestId('perps-market-detail-favorite-button');
13
+ const readState = () => page.evaluate(`(() => {
14
+ const market = ${JSON.stringify(market)};
15
+ const pair = String(document.querySelector('[data-testid="perps-market-detail-pair"]')?.innerText || '')
16
+ .trim()
17
+ .toUpperCase();
18
+ if (!pair.startsWith(market + '-')) throw new Error('Visible Perps market does not match the requested favorite market.');
19
+ const button = document.querySelector(${JSON.stringify(selector)});
20
+ if (!button) throw new Error('Perps favorite control is not visible.');
21
+ const label = String(button.getAttribute('aria-label') || button.innerText || '').trim();
22
+ return { label, favorite: /^remove from favorites$/iu.test(label) };
23
+ })()`);
24
+ const before = await readState();
25
+ if (before.favorite !== desired) {
26
+ await page.click(selector);
27
+ }
28
+ const deadline = Date.now() + 10_000;
29
+ let after = before;
30
+ while (Date.now() <= deadline) {
31
+ after = await readState();
32
+ if (after.favorite === desired) break;
33
+ await new Promise((resolve) => setTimeout(resolve, 100));
34
+ }
35
+ if (after.favorite !== desired) {
36
+ throw new Error(`Perps favorite state did not become ${desired}.`);
37
+ }
38
+ return {
39
+ action: input.action,
40
+ market,
41
+ requestedFavorite: desired,
42
+ changed: before.favorite !== after.favorite,
43
+ before,
44
+ after,
45
+ proofPath: 'trusted-pointer-visible-ui',
46
+ };
47
+ }));