@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
@@ -0,0 +1,106 @@
1
+ import { dataTestId, marketSymbol, runAdapter, withExtensionPage } from '../platform/cdp.mjs';
2
+
3
+ function displayedPrice(value) {
4
+ const match = String(value).replace(/,/gu, '').match(/-?\d+(?:\.\d+)?/u);
5
+ return match ? Number(match[0]) : null;
6
+ }
7
+
8
+ function matchesRequestedPrice(displayed, requested) {
9
+ const observed = displayedPrice(displayed);
10
+ const expected = Number(requested);
11
+ return observed !== null && Number.isFinite(observed)
12
+ && Math.abs(observed - expected) <= Math.max(0.005, Math.abs(expected) * 1e-10);
13
+ }
14
+
15
+ runAdapter(async (input) => withExtensionPage(input, async (page) => {
16
+ const market = marketSymbol(input);
17
+ const takeProfit = input.node?.take_profit_price;
18
+ const stopLoss = input.node?.stop_loss_price;
19
+ if (!market) throw new Error('metamask.perps.update_position_tpsl requires market=<symbol>.');
20
+ if (takeProfit == null && stopLoss == null) {
21
+ throw new Error('metamask.perps.update_position_tpsl requires take_profit_price and/or stop_loss_price.');
22
+ }
23
+ for (const [name, value] of [['take_profit_price', takeProfit], ['stop_loss_price', stopLoss]]) {
24
+ if (value != null && (!Number.isFinite(Number(value)) || Number(value) <= 0)) {
25
+ throw new Error(`metamask.perps.update_position_tpsl received invalid ${name}.`);
26
+ }
27
+ }
28
+ if (input.node?.confirm_live !== true) {
29
+ throw new Error('metamask.perps.update_position_tpsl requires confirm_live=true for live submission.');
30
+ }
31
+
32
+ await page.navigateHash(`#/perps/market/${encodeURIComponent(market)}`);
33
+ await page.waitForSelector(dataTestId('perps-auto-close-row'), { timeoutMs: 20_000 });
34
+ const before = await page.evaluate(`(() => ({
35
+ takeProfit: String(document.querySelector('[data-testid="perps-auto-close-tp-value"]')?.textContent || '').trim(),
36
+ stopLoss: String(document.querySelector('[data-testid="perps-auto-close-sl-value"]')?.textContent || '').trim(),
37
+ }))()`);
38
+ await page.click(dataTestId('perps-auto-close-row'));
39
+ await page.waitForSelector(dataTestId('perps-update-tpsl-modal'), { timeoutMs: 10_000 });
40
+ if (takeProfit != null) {
41
+ await page.setInput('input[data-testid="perps-update-tpsl-tp-price-input"], [data-testid="perps-update-tpsl-tp-price-input"] input', String(takeProfit));
42
+ }
43
+ if (stopLoss != null) {
44
+ await page.setInput('input[data-testid="perps-update-tpsl-sl-price-input"], [data-testid="perps-update-tpsl-sl-price-input"] input', String(stopLoss));
45
+ }
46
+
47
+ const readyDeadline = Date.now() + 10_000;
48
+ let ready;
49
+ while (Date.now() <= readyDeadline) {
50
+ ready = await page.evaluate(`(() => {
51
+ const value = (testId) => document.querySelector('input[data-testid="' + testId + '"], [data-testid="' + testId + '"] input')?.value ?? null;
52
+ const submit = document.querySelector('[data-testid="perps-update-tpsl-modal-submit"]');
53
+ return {
54
+ takeProfit: value('perps-update-tpsl-tp-price-input'),
55
+ stopLoss: value('perps-update-tpsl-sl-price-input'),
56
+ submitEnabled: Boolean(submit && !submit.disabled && submit.getAttribute('aria-disabled') !== 'true'),
57
+ entry: String(document.querySelector('[data-testid="perps-update-tpsl-entry-price-value"]')?.textContent || '').trim(),
58
+ current: String(document.querySelector('[data-testid="perps-update-tpsl-current-price-value"]')?.textContent || '').trim(),
59
+ liquidation: String(document.querySelector('[data-testid="perps-update-tpsl-liquidation-price-value"]')?.textContent || '').trim(),
60
+ errors: [...document.querySelectorAll('[role="alert"]')]
61
+ .map((element) => String(element.innerText || '').replace(/\\s+/gu, ' ').trim())
62
+ .filter(Boolean)
63
+ .slice(0, 20),
64
+ };
65
+ })()`);
66
+ const retained = (takeProfit == null || Number(ready.takeProfit) === Number(takeProfit)) &&
67
+ (stopLoss == null || Number(ready.stopLoss) === Number(stopLoss));
68
+ if (retained && ready.submitEnabled) break;
69
+ await new Promise((resolve) => setTimeout(resolve, 100));
70
+ }
71
+ if (!ready?.submitEnabled || ready.errors.length > 0) {
72
+ throw new Error(`Perps TP/SL update is not ready: ${JSON.stringify(ready)}.`);
73
+ }
74
+ await page.click(dataTestId('perps-update-tpsl-modal-submit'));
75
+
76
+ const resultDeadline = Date.now() + Number(input.node?.timeout_ms ?? 30_000);
77
+ const reconciled = (state) =>
78
+ (takeProfit == null || matchesRequestedPrice(state.takeProfit, takeProfit)) &&
79
+ (stopLoss == null || matchesRequestedPrice(state.stopLoss, stopLoss));
80
+ let after;
81
+ while (Date.now() <= resultDeadline) {
82
+ after = await page.evaluate(`(() => ({
83
+ modalVisible: Boolean(document.querySelector('[data-testid="perps-update-tpsl-modal"]')),
84
+ takeProfit: String(document.querySelector('[data-testid="perps-auto-close-tp-value"]')?.textContent || '').trim(),
85
+ stopLoss: String(document.querySelector('[data-testid="perps-auto-close-sl-value"]')?.textContent || '').trim(),
86
+ errors: [...document.querySelectorAll('[role="alert"]')]
87
+ .map((element) => String(element.innerText || '').replace(/\\s+/gu, ' ').trim())
88
+ .filter(Boolean)
89
+ .slice(0, 20),
90
+ }))()`);
91
+ if (!after.modalVisible && reconciled(after)) break;
92
+ if (after.errors.length > 0) throw new Error(`Perps TP/SL update failed: ${after.errors.join(' | ')}`);
93
+ await new Promise((resolve) => setTimeout(resolve, 200));
94
+ }
95
+ if (!after || after.modalVisible || !reconciled(after)) {
96
+ throw new Error(`Perps TP/SL did not reconcile after submission: ${JSON.stringify({ before, after })}.`);
97
+ }
98
+ return {
99
+ action: input.action,
100
+ market,
101
+ before,
102
+ ready,
103
+ after,
104
+ proofPath: 'visible-product-controls',
105
+ };
106
+ }));
@@ -67,7 +67,10 @@ export function extensionTarget(targets, extensionId = null) {
67
67
  if (extensionId && extensionIdFromTarget(target) !== extensionId) return false;
68
68
  return Boolean(target.webSocketDebuggerUrl);
69
69
  });
70
- const home = candidates.find((target) => String(target.url).includes('/home.html'));
70
+ const home = candidates.find((target) =>
71
+ String(target.url).includes('/home.html') &&
72
+ !String(target.url).includes('/onboarding/')
73
+ ) ?? candidates.find((target) => String(target.url).includes('/home.html'));
71
74
  const sidepanel = candidates.find((target) => String(target.url).includes('/sidepanel.html'));
72
75
  if (home && String(home.url).includes('/onboarding/completion') && sidepanel) {
73
76
  return sidepanel;
@@ -729,6 +732,106 @@ export class ExtensionPage extends CdpWebPage {
729
732
  this.port = port;
730
733
  }
731
734
 
735
+ async click(selector) {
736
+ const target = await this.evaluate(`(() => {
737
+ const selector = ${JSON.stringify(selector)};
738
+ const element = document.querySelector(selector);
739
+ if (!element) throw new Error('Clickable element not found: ' + selector);
740
+ element.scrollIntoView({ block: 'center', inline: 'center' });
741
+ const rect = element.getBoundingClientRect();
742
+ const style = getComputedStyle(element);
743
+ if (style.display === 'none' || style.visibility === 'hidden' || rect.width <= 0 || rect.height <= 0) {
744
+ throw new Error('Clickable element is not visible: ' + selector);
745
+ }
746
+ return {
747
+ x: rect.left + (rect.width / 2),
748
+ y: rect.top + (rect.height / 2),
749
+ tagName: element.tagName,
750
+ };
751
+ })()`);
752
+ await this.session.call('Input.dispatchMouseEvent', {
753
+ type: 'mouseMoved',
754
+ x: target.x,
755
+ y: target.y,
756
+ });
757
+ await this.session.call('Input.dispatchMouseEvent', {
758
+ type: 'mousePressed',
759
+ x: target.x,
760
+ y: target.y,
761
+ button: 'left',
762
+ buttons: 1,
763
+ clickCount: 1,
764
+ });
765
+ await this.session.call('Input.dispatchMouseEvent', {
766
+ type: 'mouseReleased',
767
+ x: target.x,
768
+ y: target.y,
769
+ button: 'left',
770
+ buttons: 0,
771
+ clickCount: 1,
772
+ });
773
+ return { clicked: true, selector, tagName: target.tagName };
774
+ }
775
+
776
+ async setInput(selector, value) {
777
+ const input = String(value ?? '');
778
+ const target = await this.evaluate(`(() => {
779
+ const selector = ${JSON.stringify(selector)};
780
+ const element = document.querySelector(selector);
781
+ if (!element) throw new Error('Input element not found: ' + selector);
782
+ if (!('value' in element)) throw new Error('Target is not an input: ' + selector);
783
+ element.focus();
784
+ if (typeof element.select === 'function') element.select();
785
+ return { tagName: element.tagName, previousLength: String(element.value ?? '').length };
786
+ })()`);
787
+ await this.session.call('Input.dispatchKeyEvent', { type: 'keyDown', key: 'Backspace' });
788
+ await this.session.call('Input.dispatchKeyEvent', { type: 'keyUp', key: 'Backspace' });
789
+ for (const character of input) {
790
+ const upper = character.toUpperCase();
791
+ const isLetter = /^[A-Z]$/u.test(upper);
792
+ const isDigit = /^[0-9]$/u.test(character);
793
+ const virtualKeyCode = isLetter || isDigit ? upper.charCodeAt(0) : character === ' ' ? 32 : 0;
794
+ await this.session.call('Input.dispatchKeyEvent', {
795
+ type: 'keyDown',
796
+ key: character,
797
+ ...(isLetter ? { code: `Key${upper}` } : {}),
798
+ ...(isDigit ? { code: `Digit${character}` } : {}),
799
+ ...(virtualKeyCode ? {
800
+ windowsVirtualKeyCode: virtualKeyCode,
801
+ nativeVirtualKeyCode: virtualKeyCode,
802
+ } : {}),
803
+ text: character,
804
+ unmodifiedText: character,
805
+ });
806
+ await this.session.call('Input.dispatchKeyEvent', {
807
+ type: 'keyUp',
808
+ key: character,
809
+ ...(isLetter ? { code: `Key${upper}` } : {}),
810
+ ...(isDigit ? { code: `Digit${character}` } : {}),
811
+ ...(virtualKeyCode ? {
812
+ windowsVirtualKeyCode: virtualKeyCode,
813
+ nativeVirtualKeyCode: virtualKeyCode,
814
+ } : {}),
815
+ });
816
+ }
817
+ const retainedLength = await this.evaluate(`(() => {
818
+ const element = document.querySelector(${JSON.stringify(selector)});
819
+ if (!element || !('value' in element)) return -1;
820
+ element.dispatchEvent(new Event('change', { bubbles: true }));
821
+ return String(element.value ?? '').length;
822
+ })()`);
823
+ if (retainedLength !== input.length) {
824
+ throw new Error(`Input did not retain the supplied value length: ${selector}`);
825
+ }
826
+ return {
827
+ set: true,
828
+ selector,
829
+ tagName: target.tagName,
830
+ previousLength: target.previousLength,
831
+ retainedLength,
832
+ };
833
+ }
834
+
732
835
  async navigateHash(hash, timeoutMs = 10000) {
733
836
  const normalizedHash = String(hash || '').startsWith('#') ? hash : `#${hash || '/'}`;
734
837
  const navigation = await this.evaluate(`(() => {
@@ -0,0 +1,58 @@
1
+ import { dataTestId, runAdapter, withExtensionPage } from '../platform/cdp.mjs';
2
+
3
+ const TOGGLE_SELECTOR = dataTestId('basic-functionality-toggle');
4
+ const TOGGLE_LABEL_SELECTOR = `label.toggle-button:has(${TOGGLE_SELECTOR})`;
5
+
6
+ async function readState(page) {
7
+ return page.evaluate(`(() => {
8
+ const input = document.querySelector(${JSON.stringify(TOGGLE_SELECTOR)});
9
+ const label = input?.closest('label.toggle-button');
10
+ if (!input || !label) return null;
11
+ return {
12
+ enabled: label.classList.contains('toggle-button--on'),
13
+ route: String(location.hash),
14
+ toggleClass: String(label.className),
15
+ };
16
+ })()`);
17
+ }
18
+
19
+ async function waitForState(page, enabled, timeoutMs = 15_000) {
20
+ const deadline = Date.now() + timeoutMs;
21
+ let state;
22
+ while (Date.now() <= deadline) {
23
+ state = await readState(page);
24
+ if (state?.enabled === enabled) return state;
25
+ await new Promise((resolve) => setTimeout(resolve, 100));
26
+ }
27
+ throw new Error(`Basic Functionality did not become ${enabled ? 'enabled' : 'disabled'}.`);
28
+ }
29
+
30
+ runAdapter(async (input) => withExtensionPage(input, async (page) => {
31
+ if (typeof input.node?.enabled !== 'boolean') {
32
+ throw new Error('metamask.settings.set_basic_functionality requires enabled=true or enabled=false.');
33
+ }
34
+ const requested = input.node.enabled;
35
+ await page.navigateHash('#/settings/privacy');
36
+ await page.waitForSelector(TOGGLE_LABEL_SELECTOR);
37
+ const before = await readState(page);
38
+ if (!before) throw new Error('Basic Functionality toggle is not visible in Privacy settings.');
39
+
40
+ if (before.enabled !== requested) {
41
+ await page.click(TOGGLE_LABEL_SELECTOR);
42
+ if (!requested) {
43
+ await page.waitForSelector(dataTestId('basic-configuration-checkbox'));
44
+ await page.click(dataTestId('basic-configuration-checkbox'));
45
+ await page.click(dataTestId('basic-configuration-modal-toggle-button'));
46
+ }
47
+ }
48
+
49
+ const after = await waitForState(page, requested);
50
+ return {
51
+ action: input.action,
52
+ requested,
53
+ changed: before.enabled !== after.enabled,
54
+ before,
55
+ after,
56
+ proofPath: 'trusted-pointer-visible-ui',
57
+ };
58
+ }));
@@ -0,0 +1,71 @@
1
+ import { runAdapter, withExtensionPage } from '../platform/cdp.mjs';
2
+ import {
3
+ normalizeVisibleSwapBridgeState,
4
+ resolveVisibleSwapBridgeOptions,
5
+ } from '../../shared/swap-bridge/visible-state.mjs';
6
+
7
+ runAdapter(async (input) => withExtensionPage(input, async (page) => {
8
+ const options = resolveVisibleSwapBridgeOptions(input.node);
9
+ const raw = await page.evaluate(`(() => {
10
+ const testIds = [
11
+ 'from-amount',
12
+ 'to-amount',
13
+ 'bridge-source-button',
14
+ 'bridge-destination-button',
15
+ 'bridge__header-settings-button',
16
+ 'prepare-swap-page-error',
17
+ 'bridge-missing-price-banner',
18
+ ];
19
+ const items = testIds.map((testId) => {
20
+ const element = document.querySelector('[data-testid="' + testId + '"]');
21
+ if (!element) return null;
22
+ const style = getComputedStyle(element);
23
+ const rect = element.getBoundingClientRect();
24
+ if (style.display === 'none' || style.visibility === 'hidden' || rect.width <= 0 || rect.height <= 0) return null;
25
+ const input = element.matches('input, textarea') ? element : element.querySelector('input, textarea');
26
+ return {
27
+ testId,
28
+ label: String(element.getAttribute('aria-label') || element.innerText || '').replace(/\\s+/gu, ' ').trim(),
29
+ value: input ? String(input.value ?? '') : null,
30
+ enabled: !(input ?? element).disabled,
31
+ visible: true,
32
+ };
33
+ }).filter(Boolean);
34
+ const text = (selector) => String(document.querySelector(selector)?.innerText || '').replace(/\\s+/gu, ' ').trim();
35
+ const slippageButton = document.querySelector('[data-testid="slippage-edit-button"]');
36
+ const cta = document.querySelector('[data-testid="bridge-cta-button"]');
37
+ const body = String(document.body.innerText || '').replace(/\\s+/gu, ' ').trim();
38
+ return {
39
+ route: location.hash,
40
+ title: document.title,
41
+ items,
42
+ quote: {
43
+ slippage: String(slippageButton?.parentElement?.innerText || '').replace(/\\s+/gu, ' ').trim() || null,
44
+ networkFee: text('[data-testid="network-fees-sponsored"], [data-testid="network-fees-included"], [data-testid="network-fees"]') || null,
45
+ minimumReceived: text('[data-testid="minimum-received"]') || null,
46
+ metaMaskFee: text('[data-testid="bridge-cta-info-text"]') || null,
47
+ cta: cta ? String(cta.innerText || '').replace(/\\s+/gu, ' ').trim() : null,
48
+ ctaEnabled: cta ? !cta.disabled : false,
49
+ warning: body.match(/(?:might be reverted|may fail|likely to fail|insufficient funds|trade route isn't available)[^.]{0,300}/iu)?.[0] || null,
50
+ },
51
+ };
52
+ })()`);
53
+ const state = normalizeVisibleSwapBridgeState(raw, options, 'extension');
54
+ const requireQuote = input.node?.require_quote === true;
55
+ const expectedSlippage = input.node?.expected_slippage == null
56
+ ? null
57
+ : Number(input.node.expected_slippage);
58
+ if (requireQuote && (!raw.quote.ctaEnabled || !(Number(state.destinationAmount) > 0)
59
+ || !/^(?:Swap|Bridge)$/iu.test(String(raw.quote.cta)))) {
60
+ throw new Error('Swap quote is not ready for review.');
61
+ }
62
+ if (expectedSlippage !== null && raw.quote.slippage !== `${expectedSlippage}%`) {
63
+ throw new Error(`Swap slippage mismatch: expected ${expectedSlippage}%, observed ${raw.quote.slippage ?? 'none'}.`);
64
+ }
65
+ return {
66
+ action: input.action,
67
+ ...state,
68
+ quote: raw.quote,
69
+ proofPath: 'visible-dom-accessibility',
70
+ };
71
+ }));
@@ -0,0 +1,103 @@
1
+ import { runAdapter, withExtensionPage, dataTestId } from '../platform/cdp.mjs';
2
+ import { openSwap } from '../ui/navigate.mjs';
3
+ import { resolveAssetSelectionOptions } from '../../shared/swap-bridge/transaction.mjs';
4
+
5
+ async function pause(page) {
6
+ await page.evaluate('new Promise((resolve) => setTimeout(resolve, 200))');
7
+ }
8
+
9
+ async function isVisible(page, selector) {
10
+ return page.evaluate(`(() => {
11
+ const element = document.querySelector(${JSON.stringify(selector)});
12
+ if (!element) return false;
13
+ const style = getComputedStyle(element);
14
+ const rect = element.getBoundingClientRect();
15
+ return style.display !== 'none' && style.visibility !== 'hidden' && rect.width > 0 && rect.height > 0;
16
+ })()`);
17
+ }
18
+
19
+ async function findAsset(page, { chain, symbol, assetId }, deadline) {
20
+ if (assetId) {
21
+ const testId = `bridge-asset--${assetId}`;
22
+ await page.waitForSelector(dataTestId(testId), { timeoutMs: Math.max(1, deadline - Date.now()) });
23
+ return testId;
24
+ }
25
+ while (Date.now() < deadline) {
26
+ const candidates = await page.evaluate(`(() => {
27
+ const prefix = ${JSON.stringify(`bridge-asset--`)} + ${JSON.stringify(`${chain}/`)};
28
+ const expected = ${JSON.stringify(symbol)};
29
+ return [...document.querySelectorAll('[data-testid^="bridge-asset--"]')]
30
+ .filter((row) => String(row.getAttribute('data-testid') || '').startsWith(prefix))
31
+ .filter((row) => String(row.querySelector('[data-testid="bridge-asset-symbol"]')?.textContent || '').trim().toUpperCase() === expected)
32
+ .map((row) => row.getAttribute('data-testid'))
33
+ .filter(Boolean);
34
+ })()`);
35
+ if (candidates.length === 1) return candidates[0];
36
+ if (candidates.length > 1) {
37
+ throw new Error(`Swap asset selection is ambiguous for ${symbol} on ${chain}: ${candidates.join(', ')}.`);
38
+ }
39
+ await pause(page);
40
+ }
41
+ throw new Error(`Swap asset ${symbol} on ${chain} did not appear after search.`);
42
+ }
43
+
44
+ async function selectAsset(page, role, asset, deadline) {
45
+ const button = dataTestId(role === 'source' ? 'bridge-source-button' : 'bridge-destination-button');
46
+ const search = dataTestId('bridge-asset-picker-search-input');
47
+ while (Date.now() < deadline && !(await isVisible(page, search))) {
48
+ if (await isVisible(page, button)) {
49
+ try {
50
+ await page.click(button);
51
+ } catch {
52
+ await pause(page);
53
+ }
54
+ }
55
+ await pause(page);
56
+ }
57
+ if (!(await isVisible(page, search))) {
58
+ throw new Error(`Swap ${role} asset picker did not open.`);
59
+ }
60
+ await page.setInput(search, asset.query);
61
+ const testId = await findAsset(page, asset, deadline);
62
+ await page.click(dataTestId(testId));
63
+ while (Date.now() < deadline) {
64
+ const selected = await page.evaluate(`String(document.querySelector(${JSON.stringify(button)})?.innerText || '').trim()`);
65
+ if (selected.toUpperCase().includes(asset.symbol)) return { role, testId, symbol: asset.symbol, chain: asset.chain };
66
+ await pause(page);
67
+ }
68
+ throw new Error(`Swap ${role} asset ${asset.symbol} was not selected.`);
69
+ }
70
+
71
+ runAdapter(async (input) => withExtensionPage(input, async (page) => {
72
+ const options = resolveAssetSelectionOptions(input.node);
73
+ const deadline = Date.now() + options.timeoutMs;
74
+ await openSwap(page, Math.max(1, deadline - Date.now()));
75
+ const source = await selectAsset(page, 'source', {
76
+ chain: options.sourceChain,
77
+ symbol: options.sourceSymbol,
78
+ query: options.sourceQuery,
79
+ assetId: options.sourceAssetId,
80
+ }, deadline);
81
+ await page.evaluate('new Promise((resolve) => setTimeout(resolve, 1000))');
82
+ const destination = await selectAsset(page, 'destination', {
83
+ chain: options.destinationChain,
84
+ symbol: options.destinationSymbol,
85
+ query: options.destinationQuery,
86
+ assetId: options.destinationAssetId,
87
+ }, deadline);
88
+ await page.evaluate('new Promise((resolve) => setTimeout(resolve, 1000))');
89
+ const stable = await page.evaluate(`(() => ({
90
+ source: String(document.querySelector('[data-testid="bridge-source-button"]')?.innerText || '').trim(),
91
+ destination: String(document.querySelector('[data-testid="bridge-destination-button"]')?.innerText || '').trim(),
92
+ }))()`);
93
+ if (!stable.source.toUpperCase().includes(options.sourceSymbol)
94
+ || !stable.destination.toUpperCase().includes(options.destinationSymbol)) {
95
+ throw new Error(`Swap pair did not remain selected: observed ${stable.source} to ${stable.destination}.`);
96
+ }
97
+ return {
98
+ action: input.action,
99
+ source,
100
+ destination,
101
+ proofPath: 'visible-ui-selection',
102
+ };
103
+ }));
@@ -0,0 +1,36 @@
1
+ import { runAdapter, withExtensionPage, dataTestId } from '../platform/cdp.mjs';
2
+ import { resolveAmountOptions } from '../../shared/swap-bridge/transaction.mjs';
3
+
4
+ runAdapter(async (input) => withExtensionPage(input, async (page) => {
5
+ const options = resolveAmountOptions(input.node);
6
+ const source = dataTestId('from-amount');
7
+ await page.setInput(source, options.amount);
8
+ const deadline = Date.now() + options.timeoutMs;
9
+ let observed;
10
+ do {
11
+ observed = await page.evaluate(`(() => {
12
+ const value = (testId) => {
13
+ const root = document.querySelector('[data-testid="' + testId + '"]');
14
+ const input = root?.matches('input, textarea') ? root : root?.querySelector('input, textarea');
15
+ return String(input?.value ?? '').trim();
16
+ };
17
+ const cta = document.querySelector('[data-testid="bridge-cta-button"]');
18
+ return {
19
+ sourceAmount: value('from-amount'),
20
+ destinationAmount: value('to-amount'),
21
+ cta: cta ? String(cta.innerText || '').trim() : null,
22
+ ctaEnabled: cta ? !cta.disabled : false,
23
+ noQuote: document.body.innerText.includes("This trade route isn't available right now"),
24
+ };
25
+ })()`);
26
+ if (!options.waitForQuote || observed.noQuote || (Number(observed.destinationAmount) > 0 && observed.cta && observed.ctaEnabled)) break;
27
+ await page.evaluate('new Promise((resolve) => setTimeout(resolve, 250))');
28
+ } while (Date.now() < deadline);
29
+ if (observed.sourceAmount !== options.amount) {
30
+ throw new Error(`Swap source amount mismatch: expected ${options.amount}, observed ${observed.sourceAmount}.`);
31
+ }
32
+ if (options.waitForQuote && !observed.noQuote && !(Number(observed.destinationAmount) > 0 && observed.cta && observed.ctaEnabled)) {
33
+ throw new Error(`Swap quote did not resolve within ${options.timeoutMs}ms.`);
34
+ }
35
+ return { action: input.action, ...observed, proofPath: 'visible-ui-amount' };
36
+ }));
@@ -0,0 +1,58 @@
1
+ import { runAdapter, withExtensionPage } from '../platform/cdp.mjs';
2
+ import { resolveAmountOptions } from '../../shared/swap-bridge/transaction.mjs';
3
+
4
+ async function pause(page) {
5
+ await page.evaluate('new Promise((resolve) => setTimeout(resolve, 250))');
6
+ }
7
+
8
+ runAdapter(async (input) => withExtensionPage(input, async (page) => {
9
+ const options = resolveAmountOptions({
10
+ amount: '1',
11
+ wait_for_quote: input.node?.wait_for_quote,
12
+ timeout_ms: input.node?.timeout_ms,
13
+ });
14
+ const marker = `mm-harness-${Date.now()}-${Math.random().toString(16).slice(2)}`;
15
+ const selector = await page.evaluate(`(() => {
16
+ const marker = ${JSON.stringify(marker)};
17
+ for (const element of document.querySelectorAll('button, [role="button"]')) {
18
+ const label = String(element.innerText || element.textContent || '').replace(/\\s+/gu, ' ').trim();
19
+ const style = getComputedStyle(element);
20
+ const rect = element.getBoundingClientRect();
21
+ if (label !== 'Max' || style.display === 'none' || style.visibility === 'hidden' || rect.width <= 0 || rect.height <= 0) continue;
22
+ element.setAttribute('data-mm-harness-target', marker);
23
+ return '[data-mm-harness-target="' + marker + '"]';
24
+ }
25
+ return null;
26
+ })()`);
27
+ if (!selector) throw new Error('Swap Max control was not visible.');
28
+ await page.click(selector);
29
+ const deadline = Date.now() + options.timeoutMs;
30
+ let observed = null;
31
+ while (Date.now() < deadline) {
32
+ observed = await page.evaluate(`(() => {
33
+ const value = (testId) => {
34
+ const root = document.querySelector('[data-testid="' + testId + '"]');
35
+ const input = root?.matches('input, textarea') ? root : root?.querySelector('input, textarea');
36
+ return String(input?.value ?? '').trim();
37
+ };
38
+ const cta = document.querySelector('[data-testid="bridge-cta-button"]');
39
+ return {
40
+ sourceAmount: value('from-amount'),
41
+ destinationAmount: value('to-amount'),
42
+ cta: cta ? String(cta.innerText || '').replace(/\\s+/gu, ' ').trim() : null,
43
+ ctaEnabled: Boolean(cta && !cta.disabled),
44
+ loading: Boolean(document.querySelector('[data-testid="multichain-bridge-quote-card-loading"]')),
45
+ noQuote: document.body.innerText.includes("This trade route isn't available right now"),
46
+ };
47
+ })()`);
48
+ if (Number(observed.sourceAmount) > 0
49
+ && (!options.waitForQuote || observed.noQuote || (!observed.loading && Number(observed.destinationAmount) > 0 && observed.ctaEnabled))) break;
50
+ await pause(page);
51
+ }
52
+ if (!(Number(observed?.sourceAmount) > 0)) throw new Error('Swap Max did not populate a positive source amount.');
53
+ if (options.waitForQuote && !observed.noQuote
54
+ && (observed.loading || !(Number(observed.destinationAmount) > 0) || !observed.ctaEnabled)) {
55
+ throw new Error(`Swap Max quote did not resolve within ${options.timeoutMs}ms.`);
56
+ }
57
+ return { action: input.action, ...observed, proofPath: 'trusted-pointer-visible-ui' };
58
+ }));