@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,12 +1,36 @@
1
1
  import { runAdapter, withExtensionPage } from '../platform/cdp.mjs';
2
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.');
3
+ function expectedList(value, name) {
4
+ if (value == null) return undefined;
5
+ if (!Array.isArray(value)) {
6
+ throw new Error(`metamask.perps.read_funds_confirmation ${name} must be an array.`);
7
7
  }
8
+ return value.map((entry) => String(entry).trim()).filter(Boolean);
9
+ }
10
+
11
+ function matchesExpectedObservation(observed, node, expectedControls, requiredText) {
12
+ return (
13
+ (typeof node.expected_submit_enabled !== 'boolean' || observed.submitEnabled === node.expected_submit_enabled) &&
14
+ (node.expected_submit_label == null || observed.submitLabel === String(node.expected_submit_label)) &&
15
+ (node.expected_amount == null || observed.amount === String(node.expected_amount)) &&
16
+ (node.expected_amount_symbol == null || observed.amountSymbol === String(node.expected_amount_symbol)) &&
17
+ (typeof node.expected_pay_token_amount_visible !== 'boolean' || observed.payTokenAmountVisible === node.expected_pay_token_amount_visible) &&
18
+ (node.expected_pay_token == null || observed.payToken === String(node.expected_pay_token)) &&
19
+ (node.expected_receive_token == null || observed.receiveToken === String(node.expected_receive_token)) &&
20
+ (node.expected_visible_network == null || observed.visibleNetwork === String(node.expected_visible_network)) &&
21
+ (!expectedControls || (
22
+ expectedControls.length === observed.percentageControls.length &&
23
+ expectedControls.every((control) => observed.percentageControls.includes(control))
24
+ )) &&
25
+ requiredText.every((text) => observed.bodyText.includes(text))
26
+ );
27
+ }
28
+
29
+ export async function readFundsConfirmation(page, node, mode, timeoutMs = 30_000) {
8
30
  const expectedTitle = mode === 'deposit' ? 'Deposit funds to Perps' : 'Withdraw';
9
- const deadline = Date.now() + 30_000;
31
+ const expectedControls = expectedList(node.expected_percentage_controls, 'expected_percentage_controls');
32
+ const requiredText = expectedList(node.require_visible_text, 'require_visible_text') ?? [];
33
+ const deadline = Date.now() + timeoutMs;
10
34
  let observed;
11
35
  while (Date.now() <= deadline) {
12
36
  observed = await page.evaluate(`(() => {
@@ -14,17 +38,50 @@ runAdapter(async (input) => withExtensionPage(input, async (page) => {
14
38
  .replace(/\\s+/gu, ' ')
15
39
  .trim()
16
40
  .slice(0, 500);
41
+ const readSymbol = () => {
42
+ const element = document.querySelector('[data-testid="pay-with-symbol"]');
43
+ const textNode = [...(element?.childNodes || [])]
44
+ .find((child) => child.nodeType === 3 && String(child.textContent || '').trim());
45
+ return String(textNode?.textContent || element?.innerText || '').replace(/\\s+/gu, ' ').trim();
46
+ };
17
47
  const amount = document.querySelector('[data-testid="custom-amount-input"]');
18
48
  const submit = document.querySelector('[data-testid="confirm-footer-button"]');
49
+ const headerBack = document.querySelector('[data-testid="wallet-initiated-header-back-button"]');
50
+ const payWithPill = document.querySelector('[data-testid="pay-with-pill"]');
51
+ const visible = (element) => {
52
+ if (!element) return false;
53
+ const style = getComputedStyle(element);
54
+ const rect = element.getBoundingClientRect();
55
+ return style.display !== 'none' && style.visibility !== 'hidden' && rect.width > 0 && rect.height > 0;
56
+ };
57
+ const bodyText = String(document.body?.innerText || '').replace(/\\s+/gu, ' ').trim().slice(0, 10_000);
58
+ const iconNames = [...(payWithPill?.querySelectorAll('img[alt$=" logo"]') || [])]
59
+ .filter(visible)
60
+ .map((element) => String(element.getAttribute('alt') || '').replace(/ logo$/u, '').trim())
61
+ .filter(Boolean);
62
+ const fundsRow = read('pay-with-row');
63
+ const fundsToken = readSymbol();
19
64
  return {
20
65
  route: String(location.hash),
21
- title: String(document.body?.innerText || '').trim().split(/\\n+/u)[0] || '',
66
+ title: String(headerBack?.parentElement?.innerText || '').replace(/\\s+/gu, ' ').trim() ||
67
+ String(document.body?.innerText || '').trim().split(/\\n+/u)[0] || '',
68
+ bodyText,
22
69
  amount: amount && 'value' in amount ? String(amount.value) : null,
70
+ amountSymbol: read('custom-amount-symbol'),
23
71
  availableBalance: read('perps-withdraw-balance-value'),
24
72
  transactionFee: read('transaction-fee-value'),
25
73
  estimatedTime: read('bridge-time-value'),
26
74
  receive: read('receive-value'),
27
- payToken: String(document.body?.innerText || '').match(/Pay with\\s+([^\\n]+)/u)?.[1] || '',
75
+ payToken: /^Pay with(?:\\s|$)/u.test(fundsRow) ? fundsToken : '',
76
+ receiveToken: /^Receive(?:\\s|$)/u.test(fundsRow) ? fundsToken : '',
77
+ visibleNetwork: read('pay-with-network'),
78
+ selectedIconNames: iconNames,
79
+ payTokenAmountVisible: visible(document.querySelector('[data-testid="pay-token-amount"]')),
80
+ percentageControls: [...document.querySelectorAll('[data-testid^="percentage-button-"]')]
81
+ .filter(visible)
82
+ .map((element) => String(element.getAttribute('data-testid') || ''))
83
+ .slice(0, 10),
84
+ loadingVisible: [...document.querySelectorAll('[data-testid$="-skeleton"]')].some(visible),
28
85
  submitLabel: String(submit?.innerText || '').trim(),
29
86
  submitEnabled: Boolean(submit && !submit.disabled && submit.getAttribute('aria-disabled') !== 'true'),
30
87
  layout: {
@@ -45,25 +102,81 @@ runAdapter(async (input) => withExtensionPage(input, async (page) => {
45
102
  },
46
103
  };
47
104
  })()`);
48
- if (observed.title === expectedTitle && observed.amount !== null) break;
105
+ const previewReady = node.require_preview_fields !== true || (
106
+ observed.transactionFee && observed.estimatedTime && observed.receive &&
107
+ (observed.payToken || observed.receiveToken) && !observed.loadingVisible
108
+ );
109
+ if (
110
+ observed.title === expectedTitle &&
111
+ observed.amount !== null &&
112
+ previewReady &&
113
+ matchesExpectedObservation(observed, node, expectedControls, requiredText)
114
+ ) break;
49
115
  await new Promise((resolve) => setTimeout(resolve, 100));
50
116
  }
51
117
  if (observed?.title !== expectedTitle || observed?.amount === null) {
52
118
  throw new Error(`Perps ${mode} confirmation did not finish loading.`);
53
119
  }
54
- if (input.node?.require_no_horizontal_overflow === true && (
120
+ if (node.require_no_horizontal_overflow === true && (
55
121
  observed.layout.horizontalOverflow || observed.layout.clippedTestIds.length > 0
56
122
  )) {
57
123
  throw new Error(`Visible Perps funds layout overflows horizontally: ${JSON.stringify(observed.layout)}.`);
58
124
  }
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}.`);
125
+ if (typeof node.expected_submit_enabled === 'boolean'
126
+ && observed.submitEnabled !== node.expected_submit_enabled) {
127
+ throw new Error(`Perps ${mode} submit state mismatch: expected ${node.expected_submit_enabled}, observed ${observed.submitEnabled}.`);
128
+ }
129
+ if (node.expected_amount != null && observed.amount !== String(node.expected_amount)) {
130
+ throw new Error(`Perps ${mode} amount mismatch: expected ${JSON.stringify(String(node.expected_amount))}, observed ${JSON.stringify(observed.amount)}.`);
131
+ }
132
+ if (node.expected_amount_symbol != null
133
+ && observed.amountSymbol !== String(node.expected_amount_symbol)) {
134
+ throw new Error(`Perps ${mode} amount symbol mismatch: expected ${JSON.stringify(String(node.expected_amount_symbol))}, observed ${JSON.stringify(observed.amountSymbol)}.`);
135
+ }
136
+ if (typeof node.expected_pay_token_amount_visible === 'boolean'
137
+ && observed.payTokenAmountVisible !== node.expected_pay_token_amount_visible) {
138
+ throw new Error(`Perps ${mode} token amount visibility mismatch: expected ${node.expected_pay_token_amount_visible}, observed ${observed.payTokenAmountVisible}.`);
139
+ }
140
+ if (node.expected_pay_token != null
141
+ && observed.payToken !== String(node.expected_pay_token)) {
142
+ throw new Error(`Perps ${mode} pay token mismatch: expected ${JSON.stringify(String(node.expected_pay_token))}, observed ${JSON.stringify(observed.payToken)}.`);
143
+ }
144
+ if (node.expected_receive_token != null
145
+ && observed.receiveToken !== String(node.expected_receive_token)) {
146
+ throw new Error(`Perps ${mode} receive token mismatch: expected ${JSON.stringify(String(node.expected_receive_token))}, observed ${JSON.stringify(observed.receiveToken)}.`);
147
+ }
148
+ if (node.expected_visible_network != null
149
+ && observed.visibleNetwork !== String(node.expected_visible_network)) {
150
+ throw new Error(`Perps ${mode} visible network mismatch: expected ${JSON.stringify(String(node.expected_visible_network))}, observed ${JSON.stringify(observed.visibleNetwork)}.`);
151
+ }
152
+ if (node.expected_submit_label != null
153
+ && observed.submitLabel !== String(node.expected_submit_label)) {
154
+ throw new Error(`Perps ${mode} submit label mismatch: expected ${JSON.stringify(String(node.expected_submit_label))}, observed ${JSON.stringify(observed.submitLabel)}.`);
155
+ }
156
+ if (expectedControls && (
157
+ expectedControls.length !== observed.percentageControls.length ||
158
+ expectedControls.some((control) => !observed.percentageControls.includes(control))
159
+ )) {
160
+ throw new Error(`Perps ${mode} percentage controls mismatch: expected ${JSON.stringify(expectedControls)}, observed ${JSON.stringify(observed.percentageControls)}.`);
161
+ }
162
+ const missingText = requiredText.filter((text) => !observed.bodyText.includes(text));
163
+ if (missingText.length > 0) {
164
+ throw new Error(`Perps ${mode} missing visible text: ${missingText.join(', ')}.`);
62
165
  }
63
166
  return {
64
- action: input.action,
65
167
  mode,
66
168
  ...observed,
67
169
  proofPath: 'visible-dom-accessibility',
68
170
  };
171
+ }
172
+
173
+ runAdapter(async (input) => withExtensionPage(input, async (page) => {
174
+ const mode = String(input.node?.mode ?? '').trim().toLowerCase();
175
+ if (mode !== 'deposit' && mode !== 'withdraw') {
176
+ throw new Error('metamask.perps.read_funds_confirmation mode must be deposit or withdraw.');
177
+ }
178
+ return {
179
+ action: input.action,
180
+ ...await readFundsConfirmation(page, input.node, mode),
181
+ };
69
182
  }));
@@ -67,6 +67,35 @@ export function resolveSnapshotOptions(input, now = Date.now()) {
67
67
  throw new Error('metamask.perps.read_snapshot history range must not exceed 365 days.');
68
68
  }
69
69
 
70
+ const sampling = node.sampling && typeof node.sampling === 'object' && !Array.isArray(node.sampling)
71
+ ? node.sampling
72
+ : {};
73
+ const expect = node.expect && typeof node.expect === 'object' && !Array.isArray(node.expect)
74
+ ? node.expect
75
+ : {};
76
+ const sampleCount = boundedInteger(sampling.count, 1, 1, 10, 'sampling.count');
77
+ const minimumChangedMarketCount = boundedInteger(
78
+ expect.minimum_changed_market_count,
79
+ 0,
80
+ 0,
81
+ 100,
82
+ 'expect.minimum_changed_market_count',
83
+ );
84
+ const minimumHistoryCount = boundedInteger(
85
+ expect.minimum_history_count,
86
+ 0,
87
+ 0,
88
+ 500,
89
+ 'expect.minimum_history_count',
90
+ );
91
+ const requireActivityHistoryAgreement = expect.require_activity_history_agreement === true;
92
+ if (minimumChangedMarketCount > 0 && (!sections.includes('markets') || sampleCount < 2)) {
93
+ throw new Error('metamask.perps.read_snapshot market change expectations require markets and at least two samples.');
94
+ }
95
+ if ((minimumHistoryCount > 0 || requireActivityHistoryAgreement) && !sections.includes('history')) {
96
+ throw new Error('metamask.perps.read_snapshot history expectations require the history section.');
97
+ }
98
+
70
99
  return {
71
100
  include: sections,
72
101
  markets: requestedMarkets(node),
@@ -80,6 +109,15 @@ export function resolveSnapshotOptions(input, now = Date.now()) {
80
109
  : String(history.account_id),
81
110
  limit: boundedInteger(history.limit, 100, 1, 500, 'history.limit'),
82
111
  },
112
+ sampling: {
113
+ count: sampleCount,
114
+ intervalMs: boundedInteger(sampling.interval_ms, 1_000, 100, 30_000, 'sampling.interval_ms'),
115
+ },
116
+ expect: {
117
+ minimumChangedMarketCount,
118
+ minimumHistoryCount,
119
+ requireActivityHistoryAgreement,
120
+ },
83
121
  };
84
122
  }
85
123
 
@@ -174,6 +212,7 @@ export function snapshotRequestExpression(options) {
174
212
  stopLossPrice: field(item, ['stopLossPrice']),
175
213
  };
176
214
  return {
215
+ activityId: field(item, ['id', 'depositId', 'withdrawalId']),
177
216
  type: field(item, ['type', 'activityType']),
178
217
  status: field(item, ['status']),
179
218
  asset: field(item, ['asset', 'token', 'currency']),
@@ -339,6 +378,7 @@ function normalizeOrder(item) {
339
378
 
340
379
  function normalizeHistoryItem(item) {
341
380
  return {
381
+ activityId: field(item, ['activityId', 'id', 'depositId', 'withdrawalId']),
342
382
  type: field(item, ['type', 'activityType']),
343
383
  status: field(item, ['status']),
344
384
  asset: field(item, ['asset', 'token', 'currency']),
@@ -438,10 +478,84 @@ export function normalizeSnapshotResult(raw, options) {
438
478
  return sections;
439
479
  }
440
480
 
441
- export async function readSnapshot(input) {
481
+ export function changedMarketFacts(first, last) {
482
+ const initial = new Map((first?.markets?.items ?? []).map((item) => [item.market, item]));
483
+ const fields = ['price', 'markPrice', 'oraclePrice', 'change24hPercent', 'fundingRate'];
484
+ return (last?.markets?.items ?? []).flatMap((item) => {
485
+ const before = initial.get(item.market);
486
+ const changedFields = before
487
+ ? fields.filter((fieldName) => before[fieldName] !== item[fieldName])
488
+ : [];
489
+ return changedFields.length > 0 ? [{ market: item.market, changedFields }] : [];
490
+ });
491
+ }
492
+
493
+ async function visibleActivityCards(page) {
494
+ return page.evaluate(`[
495
+ ...document.querySelectorAll('[data-testid^="transaction-card-"]')
496
+ ].slice(0, 200).map((element) => {
497
+ const style = getComputedStyle(element);
498
+ const rect = element.getBoundingClientRect();
499
+ return {
500
+ testId: String(element.getAttribute('data-testid') || '').slice(0, 500),
501
+ label: String(element.innerText || '').replace(/\\s+/gu, ' ').trim().slice(0, 500),
502
+ visible: style.display !== 'none' && style.visibility !== 'hidden' && rect.width > 0 && rect.height > 0,
503
+ };
504
+ })`);
505
+ }
506
+
507
+ export function activityHistoryAgreement(cards, historyItems) {
508
+ for (const item of historyItems) {
509
+ const activityId = String(item.activityId ?? '').trim();
510
+ if (!activityId) continue;
511
+ const type = String(item.type ?? '').trim().toLowerCase();
512
+ const prefix = type === 'withdraw' ? 'withdrawal' : type;
513
+ const expectedTestId = `transaction-card-${prefix}-${activityId}`;
514
+ const card = cards.find((entry) => entry.visible === true && entry.testId === expectedTestId);
515
+ if (card) {
516
+ return {
517
+ activityTestId: card.testId,
518
+ historyActivityId: activityId,
519
+ historyType: item.type,
520
+ historyStatus: item.status,
521
+ };
522
+ }
523
+ }
524
+ return null;
525
+ }
526
+
527
+ export async function readSnapshot(input, testOptions = {}) {
442
528
  const options = resolveSnapshotOptions(input);
443
529
  return withExtensionPage(input, async (page) => {
444
- const raw = await page.evaluate(snapshotRequestExpression(options));
530
+ const pause = testOptions.pause ?? ((ms) => new Promise((resolve) => setTimeout(resolve, ms)));
531
+ const samples = [];
532
+ for (let index = 0; index < options.sampling.count; index += 1) {
533
+ const raw = await page.evaluate(snapshotRequestExpression(options));
534
+ samples.push(normalizeSnapshotResult(raw, options));
535
+ if (index + 1 < options.sampling.count) await pause(options.sampling.intervalMs);
536
+ }
537
+ const sections = samples.at(-1);
538
+ const changedMarkets = changedMarketFacts(samples[0], sections);
539
+ if (changedMarkets.length < options.expect.minimumChangedMarketCount) {
540
+ throw new Error(
541
+ `Expected at least ${options.expect.minimumChangedMarketCount} changing Perps markets, but observed ${changedMarkets.length}.`,
542
+ );
543
+ }
544
+ const historyCount = sections.history?.returnedCount ?? 0;
545
+ if (historyCount < options.expect.minimumHistoryCount) {
546
+ throw new Error(
547
+ `Expected at least ${options.expect.minimumHistoryCount} Perps history items, but observed ${historyCount}.`,
548
+ );
549
+ }
550
+ const visibleCards = options.expect.requireActivityHistoryAgreement
551
+ ? await visibleActivityCards(page)
552
+ : [];
553
+ const agreement = options.expect.requireActivityHistoryAgreement
554
+ ? activityHistoryAgreement(visibleCards, sections.history?.items ?? [])
555
+ : null;
556
+ if (options.expect.requireActivityHistoryAgreement && !agreement) {
557
+ throw new Error('Visible Perps activity did not agree with any normalized history identity.');
558
+ }
445
559
  return {
446
560
  action: input.action,
447
561
  source: 'extension-stateHooks-background',
@@ -454,7 +568,14 @@ export async function readSnapshot(input) {
454
568
  accountScoped: options.history.accountId !== undefined,
455
569
  }
456
570
  : null,
457
- sections: normalizeSnapshotResult(raw, options),
571
+ sections,
572
+ sampling: {
573
+ count: options.sampling.count,
574
+ intervalMs: options.sampling.intervalMs,
575
+ changedMarketCount: changedMarkets.length,
576
+ changedMarkets,
577
+ },
578
+ activityHistoryAgreement: agreement,
458
579
  redacted: true,
459
580
  proofPath: 'background-perps-snapshot',
460
581
  };
@@ -4,15 +4,59 @@ import {
4
4
  resolveVisibleStateOptions,
5
5
  } from '../../shared/perps/visible-state.mjs';
6
6
 
7
- runAdapter(async (input) => withExtensionPage(input, async (page) => {
8
- const options = resolveVisibleStateOptions(input.node);
7
+ function pointInside(point, left, top, right, bottom) {
8
+ return point && point.x >= left && point.x <= right && point.y >= top && point.y <= bottom;
9
+ }
10
+
11
+ export function classifyActionableControlLayout(controls) {
12
+ const bounded = Array.isArray(controls) ? controls.slice(0, 200) : [];
13
+ return {
14
+ obscuredControlTestIds: bounded
15
+ .filter((control) => !control.hitPoint)
16
+ .map((control) => control.testId)
17
+ .filter(Boolean)
18
+ .slice(0, 100),
19
+ overlappingControlPairs: bounded.flatMap((left, index) =>
20
+ bounded.slice(index + 1).flatMap((right) => {
21
+ if (!left.hitPoint || !right.hitPoint) return [];
22
+ const overlapLeft = Math.max(left.rect.left, right.rect.left);
23
+ const overlapTop = Math.max(left.rect.top, right.rect.top);
24
+ const overlapRight = Math.min(left.rect.right, right.rect.right);
25
+ const overlapBottom = Math.min(left.rect.bottom, right.rect.bottom);
26
+ if (overlapRight - overlapLeft <= 1 || overlapBottom - overlapTop <= 1) return [];
27
+ return pointInside(left.hitPoint, overlapLeft, overlapTop, overlapRight, overlapBottom) ||
28
+ pointInside(right.hitPoint, overlapLeft, overlapTop, overlapRight, overlapBottom)
29
+ ? [[left.testId, right.testId]]
30
+ : [];
31
+ }))
32
+ .slice(0, 100),
33
+ };
34
+ }
35
+
36
+ export async function readVisiblePerpsState(page, node = {}) {
37
+ const options = resolveVisibleStateOptions(node);
9
38
  const raw = await page.evaluate(`(() => {
10
39
  const includeOffscreen = ${JSON.stringify(options.includeOffscreen)};
11
40
  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;
41
+ 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-|back-button|direction-|order-type-|amount-|balance-percent-|leverage-|auto-close|submit-order-button|limit-price-|tp-|sl-|custom-amount-|pay-with-|confirm-footer-button|wallet-initiated-header-back-button|transaction-fee-|bridge-(?:fee|time)-|receive-(?:row|value))/u;
13
42
  const items = [];
14
43
  const offscreenItems = [];
15
44
  const clippedTestIds = [];
45
+ const visibleControls = [];
46
+ const safeHitPoint = (element, rect) => {
47
+ const left = Math.max(0, rect.left);
48
+ const top = Math.max(0, rect.top);
49
+ const right = Math.min(innerWidth, rect.right);
50
+ const bottom = Math.min(innerHeight, rect.bottom);
51
+ if (right - left <= 1 || bottom - top <= 1) return null;
52
+ for (const [xRatio, yRatio] of [[0.5, 0.5], [0.25, 0.25], [0.75, 0.25], [0.25, 0.75], [0.75, 0.75]]) {
53
+ const x = left + (right - left) * xRatio;
54
+ const y = top + (bottom - top) * yRatio;
55
+ const hit = document.elementFromPoint(x, y);
56
+ if (hit && (hit === element || element.contains(hit))) return { x, y };
57
+ }
58
+ return null;
59
+ };
16
60
  let matched = 0;
17
61
  for (const element of document.querySelectorAll('[data-testid]')) {
18
62
  const testId = element.getAttribute('data-testid');
@@ -26,6 +70,13 @@ runAdapter(async (input) => withExtensionPage(input, async (page) => {
26
70
  if (rect.bottom > 0 && rect.top < innerHeight && (rect.left < -1 || rect.right > innerWidth + 1)) {
27
71
  clippedTestIds.push(testId);
28
72
  }
73
+ if (visible && element.matches('button,input,select,textarea,[role="button"],[role="tab"],[role="switch"],[role="slider"]')) {
74
+ visibleControls.push({
75
+ testId,
76
+ hitPoint: safeHitPoint(element, rect),
77
+ rect: { left: rect.left, top: rect.top, right: rect.right, bottom: rect.bottom },
78
+ });
79
+ }
29
80
  if (!visible && !includeOffscreen) continue;
30
81
  if (items.length + offscreenItems.length >= maximum) break;
31
82
  const label = String(element.getAttribute('aria-label') || element.innerText || element.getAttribute('value') || '')
@@ -87,12 +138,19 @@ runAdapter(async (input) => withExtensionPage(input, async (page) => {
87
138
  contentWidth: Math.max(document.documentElement?.scrollWidth || 0, document.body?.scrollWidth || 0),
88
139
  horizontalOverflow: Math.max(document.documentElement?.scrollWidth || 0, document.body?.scrollWidth || 0) > innerWidth + 1,
89
140
  clippedTestIds: [...new Set(clippedTestIds)].slice(0, 100),
141
+ actionableControls: visibleControls,
90
142
  },
91
143
  };
92
144
  })()`);
145
+ Object.assign(raw.layout, classifyActionableControlLayout(raw.layout.actionableControls));
146
+ delete raw.layout.actionableControls;
93
147
  return {
94
- action: input.action,
95
148
  ...normalizeVisiblePerpsState(raw, options, 'extension'),
96
149
  proofPath: 'visible-dom-accessibility',
97
150
  };
98
- }));
151
+ }
152
+
153
+ runAdapter(async (input) => withExtensionPage(input, async (page) => ({
154
+ action: input.action,
155
+ ...await readVisiblePerpsState(page, input.node),
156
+ })));
@@ -0,0 +1,32 @@
1
+ import { dataTestId, runAdapter, withExtensionPage } from '../platform/cdp.mjs';
2
+
3
+ runAdapter(async (input) => withExtensionPage(input, async (page) => {
4
+ const query = String(input.node?.query ?? '').trim();
5
+ const timeoutMs = Number(input.node?.timeout_ms ?? 30_000);
6
+ if (!query) throw new Error('metamask.perps.search_markets requires a non-empty query.');
7
+ await page.setInput(dataTestId('search-input'), query);
8
+ const deadline = Date.now() + timeoutMs;
9
+ let marketIds = [];
10
+ while (Date.now() <= deadline) {
11
+ marketIds = await page.evaluate(`(() => [...document.querySelectorAll('[data-testid^="market-row-"]')]
12
+ .filter((element) => {
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
+ .map((element) => String(element.getAttribute('data-testid') || ''))
18
+ .filter(Boolean)
19
+ .slice(0, 100))()`);
20
+ if (marketIds.length > 0) break;
21
+ await page.evaluate('new Promise((resolve) => setTimeout(resolve, 200))');
22
+ }
23
+ if (marketIds.length === 0) {
24
+ throw new Error(`Perps market search returned no visible rows for ${JSON.stringify(query)}.`);
25
+ }
26
+ return {
27
+ action: input.action,
28
+ query,
29
+ marketIds,
30
+ proofPath: 'trusted-keyboard-visible-ui',
31
+ };
32
+ }));
@@ -1,3 +1,5 @@
1
+ import { pathToFileURL } from 'node:url';
2
+
1
3
  import { dataTestId, runAdapter, withExtensionPage } from '../platform/cdp.mjs';
2
4
 
3
5
  const FILTERS = new Map([
@@ -7,7 +9,17 @@ const FILTERS = new Map([
7
9
  ['deposits', 'deposit'],
8
10
  ]);
9
11
 
10
- runAdapter(async (input) => withExtensionPage(input, async (page) => {
12
+ function activityRoute(hash) {
13
+ const separator = hash.indexOf('?');
14
+ const pathname = separator === -1 ? hash : hash.slice(0, separator);
15
+ const query = separator === -1 ? '' : hash.slice(separator + 1);
16
+ return {
17
+ pathname,
18
+ queryFilter: new URLSearchParams(query).get('filter'),
19
+ };
20
+ }
21
+
22
+ export async function selectActivityFilter(page, input, options = {}) {
11
23
  const filter = String(input.node?.filter ?? '').trim().toLowerCase();
12
24
  const option = FILTERS.get(filter);
13
25
  if (!option) {
@@ -15,30 +27,81 @@ runAdapter(async (input) => withExtensionPage(input, async (page) => {
15
27
  }
16
28
  await page.click(dataTestId('perps-activity-filter-button'));
17
29
  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'}.`);
30
+ const timeoutMs = options.timeoutMs ?? 5_000;
31
+ const pollIntervalMs = options.pollIntervalMs ?? 100;
32
+ const deadline = Date.now() + timeoutMs;
33
+ let observed;
34
+ let route;
35
+ while (true) {
36
+ observed = await page.evaluate(`(() => {
37
+ const button = document.querySelector('[data-testid="perps-activity-filter-button"]');
38
+ const style = button ? getComputedStyle(button) : null;
39
+ const rect = button?.getBoundingClientRect();
40
+ const visible = (element) => {
41
+ if (!element) return false;
42
+ const elementStyle = getComputedStyle(element);
43
+ const elementRect = element.getBoundingClientRect();
44
+ return elementStyle.display !== 'none' && elementStyle.visibility !== 'hidden' && elementRect.width > 0 && elementRect.height > 0;
45
+ };
46
+ const cards = [...document.querySelectorAll('[data-testid^="transaction-card-"]')]
47
+ .slice(0, 200)
48
+ .flatMap((element) => {
49
+ const cardStyle = getComputedStyle(element);
50
+ const cardRect = element.getBoundingClientRect();
51
+ if (cardStyle.display === 'none' || cardStyle.visibility === 'hidden' || cardRect.width <= 0 || cardRect.height <= 0) return [];
52
+ return [{
53
+ testId: String(element.getAttribute('data-testid') || '').slice(0, 500),
54
+ label: String(element.innerText || '').replace(/\\s+/gu, ' ').trim().slice(0, 500),
55
+ }];
56
+ });
57
+ return {
58
+ hash: String(location.hash),
59
+ selected: String(button?.innerText || '').trim(),
60
+ selectedVisible: Boolean(button && style?.display !== 'none' && style?.visibility !== 'hidden' && rect.width > 0 && rect.height > 0),
61
+ cards,
62
+ entries: cards.map((card) => card.label),
63
+ loadedBoundary: visible(document.querySelector('[data-testid="perps-activity-page-skeleton"]'))
64
+ ? null
65
+ : visible(document.querySelector('[data-testid="perps-activity-page"]'))
66
+ ? cards.length > 0 ? 'visible-cards' : 'loaded-empty'
67
+ : null,
68
+ empty: String(document.body?.innerText || '').replace(/\\s+/gu, ' ').trim().slice(0, 2_000),
69
+ };
70
+ })()`);
71
+ route = activityRoute(observed.hash);
72
+ if (
73
+ route.pathname === '#/perps/activity' &&
74
+ route.queryFilter === option &&
75
+ observed.selectedVisible &&
76
+ observed.selected.toLowerCase() === filter &&
77
+ observed.loadedBoundary !== null
78
+ ) {
79
+ break;
80
+ }
81
+ if (Date.now() >= deadline) {
82
+ throw new Error(
83
+ `Perps activity filter ${filter} did not settle within ${timeoutMs}ms: ` +
84
+ `pathname=${JSON.stringify(route.pathname)}, filter=${JSON.stringify(route.queryFilter)}, ` +
85
+ `label=${JSON.stringify(observed.selected)}, visible=${observed.selectedVisible === true}.`,
86
+ );
87
+ }
88
+ await page.evaluate(`new Promise((resolve) => setTimeout(resolve, ${JSON.stringify(pollIntervalMs)}))`);
34
89
  }
35
90
  return {
36
91
  action: input.action,
37
92
  filter,
93
+ pathname: route.pathname,
94
+ queryFilter: route.queryFilter,
38
95
  selected: observed.selected,
39
96
  count: observed.entries.length,
97
+ cards: observed.cards,
98
+ loadedBoundary: observed.loadedBoundary,
40
99
  entries: observed.entries,
41
100
  empty: observed.entries.length === 0 ? observed.empty : undefined,
42
101
  proofPath: 'trusted-pointer-visible-ui',
43
102
  };
44
- }));
103
+ }
104
+
105
+ if (process.argv[1] && import.meta.url === pathToFileURL(process.argv[1]).href) {
106
+ runAdapter((input) => withExtensionPage(input, (page) => selectActivityFilter(page, input)));
107
+ }