@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
@@ -75,6 +75,7 @@ runAdapter(async (input) => withExtensionPage(input, async (page) => {
75
75
  }
76
76
  const requested = input.node.visible;
77
77
  const requireUnchanged = input.node?.require_unchanged === true;
78
+ const requireChange = input.node?.require_change === true;
78
79
  const timeoutMs = Number(input.node?.timeout_ms ?? 45_000);
79
80
 
80
81
  if (!(await visible(page, MANAGE))) await page.click(OPTIONS);
@@ -91,6 +92,9 @@ runAdapter(async (input) => withExtensionPage(input, async (page) => {
91
92
  if (requireUnchanged && before.enabled !== requested) {
92
93
  throw new Error(`${query} token visibility did not persist as ${requested ? 'enabled' : 'disabled'}.`);
93
94
  }
95
+ if (requireChange && before.enabled === requested) {
96
+ throw new Error(`${query} token visibility was already ${requested ? 'enabled' : 'disabled'}.`);
97
+ }
94
98
  if (before.enabled !== requested) {
95
99
  await page.click(dataTestId(before.testId));
96
100
  }
@@ -120,6 +124,7 @@ runAdapter(async (input) => withExtensionPage(input, async (page) => {
120
124
  query,
121
125
  requested,
122
126
  requireUnchanged,
127
+ requireChange,
123
128
  changed: before.enabled !== after.enabled,
124
129
  searchResult: after.label,
125
130
  toggleTestId: after.testId,
@@ -1,5 +1,6 @@
1
1
  import { dataTestId, runAdapter, withExtensionPage } from '../platform/cdp.mjs';
2
2
  import {
3
+ assertNetworkAdditionAllowed,
3
4
  findNetworkItem,
4
5
  openNetworkManager,
5
6
  selectHomeNetwork,
@@ -17,6 +18,7 @@ runAdapter(async (input) => withExtensionPage(input, async (page) => {
17
18
  const query = String(input.node?.query || network).trim();
18
19
  const chainId = Number(input.node?.chain_id);
19
20
  const symbol = String(input.node?.symbol || '').trim().toUpperCase();
21
+ const requireAbsent = input.node?.require_absent === true;
20
22
  const timeoutMs = Number(input.node?.timeout_ms ?? 60_000);
21
23
  if (!network || !query || !Number.isInteger(chainId) || chainId <= 0 || !symbol) {
22
24
  throw new Error('metamask.networks.add_chainlist requires network, query, positive chain_id, and symbol.');
@@ -25,6 +27,7 @@ runAdapter(async (input) => withExtensionPage(input, async (page) => {
25
27
  const chainlistOpen = await page.evaluate(`Boolean(document.querySelector(${JSON.stringify(CHAINLIST_SEARCH)}))`);
26
28
  if (!chainlistOpen) await openNetworkManager(page, timeoutMs);
27
29
  let item = chainlistOpen ? null : await findNetworkItem(page, network, chainId);
30
+ assertNetworkAdditionAllowed(item, requireAbsent, network, chainId);
28
31
  let added = false;
29
32
  if (!item) {
30
33
  if (!chainlistOpen) {
@@ -87,6 +90,7 @@ runAdapter(async (input) => withExtensionPage(input, async (page) => {
87
90
  chainId,
88
91
  symbol,
89
92
  query,
93
+ requireAbsent,
90
94
  added,
91
95
  item,
92
96
  proofPath: 'trusted-pointer-chainlist-ui',
@@ -1,5 +1,6 @@
1
1
  import { dataTestId, runAdapter, withExtensionPage } from '../platform/cdp.mjs';
2
2
  import {
3
+ assertNetworkAdditionAllowed,
3
4
  findNetworkItem,
4
5
  openNetworkManager,
5
6
  selectHomeNetwork,
@@ -14,6 +15,7 @@ runAdapter(async (input) => withExtensionPage(input, async (page) => {
14
15
  const chainId = Number(input.node?.chain_id);
15
16
  const symbol = String(input.node?.symbol || '').trim();
16
17
  const explorerUrl = String(input.node?.explorer_url || '').trim();
18
+ const requireAbsent = input.node?.require_absent === true;
17
19
  const timeoutMs = Number(input.node?.timeout_ms ?? 60_000);
18
20
  if (!network || !/^https?:\/\//u.test(rpcUrl) || !Number.isInteger(chainId) || chainId <= 0 || !symbol) {
19
21
  throw new Error('metamask.networks.add_custom requires network, rpc_url, positive chain_id, and symbol.');
@@ -21,6 +23,7 @@ runAdapter(async (input) => withExtensionPage(input, async (page) => {
21
23
 
22
24
  await openNetworkManager(page, timeoutMs);
23
25
  let item = await findNetworkItem(page, network, chainId);
26
+ assertNetworkAdditionAllowed(item, requireAbsent, network, chainId);
24
27
  let added = false;
25
28
  if (!item) {
26
29
  await page.click(dataTestId('networks-page-add-custom-network-button'));
@@ -116,6 +119,7 @@ runAdapter(async (input) => withExtensionPage(input, async (page) => {
116
119
  symbol,
117
120
  rpcUrl,
118
121
  explorerUrl: explorerUrl || null,
122
+ requireAbsent,
119
123
  added,
120
124
  item,
121
125
  proofPath: 'trusted-pointer-visible-ui',
@@ -10,6 +10,46 @@ async function visible(page, selector) {
10
10
  })()`);
11
11
  }
12
12
 
13
+ export async function clickUncovered(page, selector, deadline) {
14
+ let point;
15
+ while (!point && Date.now() < deadline) {
16
+ point = await page.evaluate(`(() => {
17
+ const element = document.querySelector(${JSON.stringify(selector)});
18
+ if (!element) return null;
19
+ const style = getComputedStyle(element);
20
+ const rect = element.getBoundingClientRect();
21
+ if (style.display === 'none' || style.visibility === 'hidden' || rect.width <= 0 || rect.height <= 0) return null;
22
+ const offsets = [[0.25, 0.25], [0.25, 0.5], [0.25, 0.75], [0.5, 0.75], [0.75, 0.75]];
23
+ for (const [xOffset, yOffset] of offsets) {
24
+ const x = rect.left + (rect.width * xOffset);
25
+ const y = rect.top + (rect.height * yOffset);
26
+ const hit = document.elementFromPoint(x, y);
27
+ if (hit === element || element.contains(hit)) return { x, y };
28
+ }
29
+ return null;
30
+ })()`);
31
+ if (!point) await page.evaluate('new Promise((resolve) => setTimeout(resolve, 100))');
32
+ }
33
+ if (!point) throw new Error(`Network manager control has no uncovered pointer target: ${selector}`);
34
+ await page.session.call('Input.dispatchMouseEvent', { type: 'mouseMoved', x: point.x, y: point.y });
35
+ await page.session.call('Input.dispatchMouseEvent', {
36
+ type: 'mousePressed',
37
+ x: point.x,
38
+ y: point.y,
39
+ button: 'left',
40
+ buttons: 1,
41
+ clickCount: 1,
42
+ });
43
+ await page.session.call('Input.dispatchMouseEvent', {
44
+ type: 'mouseReleased',
45
+ x: point.x,
46
+ y: point.y,
47
+ button: 'left',
48
+ buttons: 0,
49
+ clickCount: 1,
50
+ });
51
+ }
52
+
13
53
  export async function waitFor(page, predicate, message, timeoutMs) {
14
54
  const deadline = Date.now() + timeoutMs;
15
55
  while (Date.now() < deadline) {
@@ -48,7 +88,8 @@ export async function semanticButton(page, label) {
48
88
  export async function openNetworkManager(page, timeoutMs) {
49
89
  const menu = dataTestId('account-options-menu-button');
50
90
  const networks = dataTestId('global-menu-networks');
51
- await page.click(menu);
91
+ const deadline = Date.now() + timeoutMs;
92
+ await clickUncovered(page, menu, deadline);
52
93
  await waitFor(page, () => visible(page, networks), 'Networks did not become visible in the account menu.', timeoutMs);
53
94
  await page.click(networks);
54
95
  await waitFor(
@@ -80,6 +121,12 @@ export async function findNetworkItem(page, network, chainId) {
80
121
  })()`);
81
122
  }
82
123
 
124
+ export function assertNetworkAdditionAllowed(item, requireAbsent, network, chainId) {
125
+ if (requireAbsent && item) {
126
+ throw new Error(`${network} (${chainId}) is already configured; refusing to reuse it because cleanup must not remove pre-existing state.`);
127
+ }
128
+ }
129
+
83
130
  export async function selectHomeNetwork(page, network, chainId, timeoutMs) {
84
131
  const sort = dataTestId('sort-by-networks');
85
132
  const chainHex = `0x${chainId.toString(16)}`;
@@ -0,0 +1,366 @@
1
+ import { pathToFileURL } from 'node:url';
2
+
3
+ import { runAdapter, withExtensionPage } from '../platform/cdp.mjs';
4
+ import {
5
+ openPerpsActivity,
6
+ openPerpsHome,
7
+ openPerpsMarketList,
8
+ } from '../ui/navigate.mjs';
9
+ import { selectActivityFilter } from './select_activity_filter.mjs';
10
+
11
+ const ACTIVITY_FILTERS = ['trades', 'orders', 'funding', 'deposits'];
12
+ const PRICE_SURFACES = new Set(['home', 'market-list', 'market-details', 'order-entry']);
13
+
14
+ function boundedInteger(value, fallback, minimum, maximum, name) {
15
+ const resolved = value == null ? fallback : Number(value);
16
+ if (!Number.isInteger(resolved) || resolved < minimum || resolved > maximum) {
17
+ throw new Error(`metamask.perps.assert_visible_consistency ${name} must be an integer from ${minimum} to ${maximum}.`);
18
+ }
19
+ return resolved;
20
+ }
21
+
22
+ export function resolveVisibleConsistencyOptions(input) {
23
+ const node = input.node ?? {};
24
+ const mode = String(node.mode ?? '').trim().toLowerCase();
25
+ if (mode !== 'market-stream' && mode !== 'watchlist-stream' && mode !== 'activity-containment' && mode !== 'price-stream') {
26
+ throw new Error(`metamask.perps.assert_visible_consistency received unsupported mode ${JSON.stringify(mode)}.`);
27
+ }
28
+ const surface = node.surface == null ? undefined : String(node.surface).trim().toLowerCase();
29
+ if (mode === 'price-stream' && !PRICE_SURFACES.has(surface)) {
30
+ throw new Error('metamask.perps.assert_visible_consistency price-stream requires a supported surface.');
31
+ }
32
+ const market = node.market == null ? undefined : String(node.market).trim().toUpperCase();
33
+ if (mode === 'price-stream' && (surface === 'home' || surface === 'market-list') && !market) {
34
+ throw new Error(`metamask.perps.assert_visible_consistency price-stream surface ${surface} requires market.`);
35
+ }
36
+ const requireUniqueIds = node.require_unique_ids === true;
37
+ if (mode === 'price-stream' && requireUniqueIds) {
38
+ throw new Error('metamask.perps.assert_visible_consistency require_unique_ids is unavailable for price-stream.');
39
+ }
40
+ return {
41
+ mode,
42
+ surface,
43
+ market,
44
+ sampleCount: boundedInteger(node.sample_count, 3, 2, 10, 'sample_count'),
45
+ sampleIntervalMs: boundedInteger(node.sample_interval_ms, 1_000, 100, 30_000, 'sample_interval_ms'),
46
+ minimumChangedCount: boundedInteger(node.minimum_changed_count, 1, 0, 200, 'minimum_changed_count'),
47
+ minimumRecentActivityCount: boundedInteger(
48
+ node.minimum_recent_activity_count,
49
+ 1,
50
+ 0,
51
+ 50,
52
+ 'minimum_recent_activity_count',
53
+ ),
54
+ timeoutMs: boundedInteger(node.timeout_ms, 30_000, 1_000, 120_000, 'timeout_ms'),
55
+ maxItems: boundedInteger(node.max_items, 100, 1, 200, 'max_items'),
56
+ requireUniqueIds,
57
+ };
58
+ }
59
+
60
+ function assertUniqueIds(entries, boundary) {
61
+ const ids = entries.map((entry) => typeof entry === 'string' ? entry : entry.testId);
62
+ const duplicates = [...new Set(ids.filter((id, index) => id && ids.indexOf(id) !== index))];
63
+ if (duplicates.length > 0) {
64
+ throw new Error(`Visible Perps ${boundary} contains duplicate identities: ${duplicates.join(', ')}.`);
65
+ }
66
+ }
67
+
68
+ async function visiblePrice(page, surface, market) {
69
+ const rowTestId = surface === 'home'
70
+ ? `perps-watchlist-${market}`
71
+ : surface === 'market-list'
72
+ ? `market-row-${market?.replace(':', '-')}`
73
+ : undefined;
74
+ const rootTestId = surface === 'market-details'
75
+ ? 'perps-market-detail-page'
76
+ : surface === 'order-entry'
77
+ ? 'perps-order-entry-page'
78
+ : rowTestId;
79
+ const priceTestId = surface === 'market-details'
80
+ ? 'perps-market-detail-price'
81
+ : surface === 'order-entry'
82
+ ? 'perps-order-entry-price'
83
+ : undefined;
84
+ return page.evaluate(`(() => {
85
+ const visible = (element) => {
86
+ if (!element) return false;
87
+ const style = getComputedStyle(element);
88
+ const rect = element.getBoundingClientRect();
89
+ return style.display !== 'none' && style.visibility !== 'hidden' && rect.width > 0 && rect.height > 0;
90
+ };
91
+ const root = document.querySelector('[data-testid=${JSON.stringify(rootTestId)}]');
92
+ const price = ${priceTestId
93
+ ? `document.querySelector('[data-testid=${JSON.stringify(priceTestId)}]')`
94
+ : 'root?.lastElementChild?.firstElementChild'};
95
+ const value = String(price?.innerText || '').replace(/\\s+/gu, ' ').trim().slice(0, 100);
96
+ const numeric = Number(value.replace(/[^0-9.-]/gu, ''));
97
+ return {
98
+ rootVisible: visible(root),
99
+ priceVisible: visible(price),
100
+ value,
101
+ positive: Number.isFinite(numeric) && numeric > 0,
102
+ };
103
+ })()`);
104
+ }
105
+
106
+ async function marketRows(page, maxItems) {
107
+ return page.evaluate(`[
108
+ ...document.querySelectorAll('[data-testid^="market-row-"]')
109
+ ].filter((element) => {
110
+ const testId = element.getAttribute('data-testid') || '';
111
+ if (testId.startsWith('market-row-ticker-') || /-(?:token-logo|asset-label)$/u.test(testId)) return false;
112
+ const style = getComputedStyle(element);
113
+ const rect = element.getBoundingClientRect();
114
+ return style.display !== 'none' && style.visibility !== 'hidden' && rect.width > 0 && rect.height > 0;
115
+ }).slice(0, ${JSON.stringify(maxItems)}).map((element) => ({
116
+ testId: String(element.getAttribute('data-testid') || '').slice(0, 500),
117
+ label: String(element.innerText || '').replace(/\\s+/gu, ' ').trim().slice(0, 500),
118
+ }))`);
119
+ }
120
+
121
+ async function watchlistRows(page, maxItems) {
122
+ return page.evaluate(`[
123
+ ...document.querySelectorAll('[data-testid^="perps-watchlist-"]')
124
+ ].filter((element) => {
125
+ const style = getComputedStyle(element);
126
+ const rect = element.getBoundingClientRect();
127
+ return style.display !== 'none' && style.visibility !== 'hidden' && rect.width > 0 && rect.height > 0;
128
+ }).slice(0, ${JSON.stringify(maxItems)}).map((element) => ({
129
+ testId: String(element.getAttribute('data-testid') || '').slice(0, 500),
130
+ label: String(element.innerText || '').replace(/\\s+/gu, ' ').trim().slice(0, 500),
131
+ }))`);
132
+ }
133
+
134
+ function changedRows(first, last) {
135
+ const initial = new Map(first.map((entry) => [entry.testId, entry.label]));
136
+ return last
137
+ .filter((entry) => initial.has(entry.testId) && initial.get(entry.testId) !== entry.label)
138
+ .map((entry) => entry.testId);
139
+ }
140
+
141
+ async function activityIds(page, maxItems) {
142
+ const selector = '[data-testid^="transaction-card-"]';
143
+ return page.evaluate(`[
144
+ ...document.querySelectorAll(${JSON.stringify(selector)})
145
+ ].filter((element) => {
146
+ const style = getComputedStyle(element);
147
+ const rect = element.getBoundingClientRect();
148
+ return style.display !== 'none' && style.visibility !== 'hidden' && rect.width > 0 && rect.height > 0;
149
+ }).slice(0, ${JSON.stringify(maxItems)}).map((element) => String(element.getAttribute('data-testid') || ''))`);
150
+ }
151
+
152
+ async function recentActivityState(page, maxItems) {
153
+ return page.evaluate(`(() => {
154
+ const visible = (element) => {
155
+ if (!element) return false;
156
+ const style = getComputedStyle(element);
157
+ const rect = element.getBoundingClientRect();
158
+ return style.display !== 'none' && style.visibility !== 'hidden' && rect.width > 0 && rect.height > 0;
159
+ };
160
+ return {
161
+ ids: [...document.querySelectorAll('[data-testid^="transaction-card-"]')]
162
+ .filter(visible)
163
+ .slice(0, ${JSON.stringify(maxItems)})
164
+ .map((element) => String(element.getAttribute('data-testid') || '')),
165
+ emptyVisible: visible(document.querySelector('[data-testid="perps-recent-activity-empty"]')),
166
+ };
167
+ })()`);
168
+ }
169
+
170
+ async function pollLoaded(read, accept, timeoutMs, pause, failure) {
171
+ const deadline = Date.now() + timeoutMs;
172
+ let observed;
173
+ while (true) {
174
+ observed = await read();
175
+ if (accept(observed)) return observed;
176
+ if (Date.now() >= deadline) throw new Error(failure(observed));
177
+ await pause();
178
+ }
179
+ }
180
+
181
+ async function filteredActivityIds(page, options, selectFilter) {
182
+ const ids = new Set();
183
+ const counts = {};
184
+ for (const filter of ACTIVITY_FILTERS) {
185
+ await selectFilter(page, filter, options.timeoutMs);
186
+ const filteredIds = await activityIds(page, options.maxItems);
187
+ if (options.requireUniqueIds) assertUniqueIds(filteredIds, `${filter} activity`);
188
+ counts[filter] = filteredIds.length;
189
+ for (const testId of filteredIds) ids.add(testId);
190
+ }
191
+ return { ids: [...ids], counts };
192
+ }
193
+
194
+ async function pollContained(page, recentIds, options, pause, selectFilter) {
195
+ const deadline = Date.now() + options.timeoutMs;
196
+ let observed = { ids: [], counts: {} };
197
+ while (true) {
198
+ observed = await filteredActivityIds(page, options, selectFilter);
199
+ if (recentIds.every((testId) => observed.ids.includes(testId))) return observed;
200
+ if (Date.now() >= deadline) {
201
+ throw new Error(`Recent Perps activity is not contained across all activity filters: ${JSON.stringify({ recentIds, ...observed })}.`);
202
+ }
203
+ await pause(options.sampleIntervalMs);
204
+ }
205
+ }
206
+
207
+ export async function assertVisibleConsistency(page, input, testOptions = {}) {
208
+ const options = resolveVisibleConsistencyOptions(input);
209
+ const pause = testOptions.pause ?? ((ms) => new Promise((resolve) => setTimeout(resolve, ms)));
210
+ if (options.mode === 'price-stream') {
211
+ const first = await pollLoaded(
212
+ () => visiblePrice(page, options.surface, options.market),
213
+ (sample) => sample.rootVisible && sample.priceVisible && sample.positive,
214
+ options.timeoutMs,
215
+ () => pause(options.sampleIntervalMs),
216
+ () => `Visible Perps ${options.surface} price did not finish loading before sampling.`,
217
+ );
218
+ const samples = [first];
219
+ for (let index = 1; index < options.sampleCount; index += 1) {
220
+ await pause(options.sampleIntervalMs);
221
+ samples.push(await visiblePrice(page, options.surface, options.market));
222
+ }
223
+ if (samples.some((sample) => !sample.rootVisible || !sample.priceVisible || !sample.positive)) {
224
+ throw new Error(`Visible Perps ${options.surface} price became unavailable while sampling.`);
225
+ }
226
+ const changedCount = samples.slice(1)
227
+ .filter((sample, index) => sample.value !== samples[index].value)
228
+ .length;
229
+ if (changedCount < options.minimumChangedCount) {
230
+ throw new Error(`Expected at least ${options.minimumChangedCount} changing visible Perps ${options.surface} price samples, but observed ${changedCount}.`);
231
+ }
232
+ return {
233
+ action: input.action,
234
+ mode: options.mode,
235
+ surface: options.surface,
236
+ market: options.market ?? null,
237
+ sampleCount: samples.length,
238
+ changedCount,
239
+ samples: samples.map((sample) => sample.value),
240
+ proofPath: 'visible-dom-accessibility',
241
+ };
242
+ }
243
+ if (options.mode === 'market-stream') {
244
+ const openMarketList = testOptions.openMarketList ?? openPerpsMarketList;
245
+ await openMarketList(page, options.timeoutMs);
246
+ const first = await pollLoaded(
247
+ () => marketRows(page, options.maxItems),
248
+ (rows) => rows.length > 0,
249
+ options.timeoutMs,
250
+ () => pause(options.sampleIntervalMs),
251
+ () => 'Visible Perps market rows did not finish loading before sampling.',
252
+ );
253
+ const samples = [first];
254
+ for (let index = 1; index < options.sampleCount; index += 1) {
255
+ await pause(options.sampleIntervalMs);
256
+ samples.push(await marketRows(page, options.maxItems));
257
+ }
258
+ if (options.requireUniqueIds) {
259
+ samples.forEach((sample, index) => assertUniqueIds(sample, `market sample ${index + 1}`));
260
+ }
261
+ const changed = changedRows(samples[0], samples.at(-1));
262
+ if (changed.length < options.minimumChangedCount) {
263
+ throw new Error(`Expected at least ${options.minimumChangedCount} changing visible Perps market rows, but observed ${changed.length}.`);
264
+ }
265
+ return {
266
+ action: input.action,
267
+ mode: options.mode,
268
+ sampleCount: samples.length,
269
+ firstCount: samples[0].length,
270
+ finalCount: samples.at(-1).length,
271
+ changedCount: changed.length,
272
+ changedTestIds: changed,
273
+ uniqueIdsVerified: options.requireUniqueIds,
274
+ proofPath: 'visible-dom-accessibility',
275
+ };
276
+ }
277
+
278
+ if (options.mode === 'watchlist-stream') {
279
+ const openHome = testOptions.openHome ?? openPerpsHome;
280
+ await openHome(page, options.timeoutMs);
281
+ const first = await pollLoaded(
282
+ () => watchlistRows(page, options.maxItems),
283
+ (rows) => rows.length > 0,
284
+ options.timeoutMs,
285
+ () => pause(options.sampleIntervalMs),
286
+ () => 'Visible Perps watchlist did not finish loading before sampling.',
287
+ );
288
+ const samples = [first];
289
+ for (let index = 1; index < options.sampleCount; index += 1) {
290
+ await pause(options.sampleIntervalMs);
291
+ samples.push(await watchlistRows(page, options.maxItems));
292
+ }
293
+ if (options.requireUniqueIds) {
294
+ samples.forEach((sample, index) => assertUniqueIds(sample, `watchlist sample ${index + 1}`));
295
+ }
296
+ const changed = changedRows(samples[0], samples.at(-1));
297
+ if (changed.length < options.minimumChangedCount) {
298
+ throw new Error(`Expected at least ${options.minimumChangedCount} changing visible Perps watchlist rows, but observed ${changed.length}.`);
299
+ }
300
+ const selectedTestId = changed[0];
301
+ const selectedMarket = selectedTestId.slice('perps-watchlist-'.length).toUpperCase();
302
+ const visibleSymbol = selectedMarket.replace(/^XYZ:/u, '');
303
+ await page.click(`[data-testid=${JSON.stringify(selectedTestId)}]`);
304
+ await pollLoaded(
305
+ () => page.evaluate(`(() => {
306
+ const pair = String(document.querySelector('[data-testid="perps-market-detail-pair"]')?.innerText || '')
307
+ .trim()
308
+ .toUpperCase();
309
+ return { pair, route: String(location.hash || '') };
310
+ })()`),
311
+ (state) => state.pair.startsWith(`${visibleSymbol}-`) && /\/perps\/market\//u.test(state.route),
312
+ options.timeoutMs,
313
+ () => pause(100),
314
+ () => `Visible Perps watchlist row ${selectedTestId} did not open its matching details.`,
315
+ );
316
+ return {
317
+ action: input.action,
318
+ mode: options.mode,
319
+ sampleCount: samples.length,
320
+ firstCount: samples[0].length,
321
+ finalCount: samples.at(-1).length,
322
+ changedCount: changed.length,
323
+ changedTestIds: changed,
324
+ selectedMarket,
325
+ selectedTestId,
326
+ matchingDetailsVisible: true,
327
+ uniqueIdsVerified: options.requireUniqueIds,
328
+ proofPath: 'visible-dom-accessibility',
329
+ };
330
+ }
331
+
332
+ const openHome = testOptions.openHome ?? openPerpsHome;
333
+ const openActivity = testOptions.openActivity ?? openPerpsActivity;
334
+ const selectFilter = testOptions.selectActivityFilter ?? ((currentPage, filter, timeoutMs) => selectActivityFilter(
335
+ currentPage,
336
+ { action: 'metamask.perps.select_activity_filter', node: { filter } },
337
+ { timeoutMs },
338
+ ));
339
+ await openHome(page, options.timeoutMs);
340
+ const recent = await pollLoaded(
341
+ () => recentActivityState(page, options.maxItems),
342
+ (state) => state.ids.length >= options.minimumRecentActivityCount &&
343
+ (state.ids.length > 0 || state.emptyVisible),
344
+ options.timeoutMs,
345
+ () => pause(options.sampleIntervalMs),
346
+ (state) => `Expected loaded recent Perps activity with at least ${options.minimumRecentActivityCount} visible cards, but observed ${state.ids.length}.`,
347
+ );
348
+ const recentIds = recent.ids;
349
+ if (options.requireUniqueIds) assertUniqueIds(recentIds, 'recent activity');
350
+ await openActivity(page, options.timeoutMs);
351
+ const full = await pollContained(page, recentIds, options, pause, selectFilter);
352
+ return {
353
+ action: input.action,
354
+ mode: options.mode,
355
+ recentCount: recentIds.length,
356
+ fullCount: full.ids.length,
357
+ filterCounts: full.counts,
358
+ containedTestIds: recentIds,
359
+ uniqueIdsVerified: options.requireUniqueIds,
360
+ proofPath: 'visible-dom-accessibility',
361
+ };
362
+ }
363
+
364
+ if (process.argv[1] && import.meta.url === pathToFileURL(process.argv[1]).href) {
365
+ runAdapter((input) => withExtensionPage(input, (page) => assertVisibleConsistency(page, input)));
366
+ }